feat: add api key authentication

This commit is contained in:
QiuSW
2026-07-02 17:40:26 +08:00
parent e34b4c65f8
commit 47e6aed7a8
11 changed files with 224 additions and 17 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
Phase 2 计费核心已完成:用户钱包、API Key、点数流水、调用记录、计费规则、汇率、计费计算、并发安全扣点与失败退点已落地。下一步是 T-301 API Key 鉴权。详见 [`docs/current-state.md`](docs/current-state.md)。
Phase 2 计费核心已完成,T-301 API Key 鉴权也已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 识别用户,且不接受 Web session。下一步是 T-302 生成标题 / 图片接口。详见 [`docs/current-state.md`](docs/current-state.md)。
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
from apps.api.errors import api_error
from apps.users.models import ApiKey
class ApiKeyAuthentication(BaseAuthentication):
keyword = "Bearer"
def authenticate(self, request):
raw_header = get_authorization_header(request)
if not raw_header:
return None
try:
header = raw_header.decode("utf-8")
except UnicodeError as exc:
raise self.authentication_failed() from exc
parts = header.split()
if len(parts) != 2 or parts[0].lower() != self.keyword.lower():
raise self.authentication_failed()
raw_key = parts[1]
if not raw_key:
raise self.authentication_failed()
key_hash = ApiKey.hash_key(raw_key)
try:
api_key = ApiKey.objects.select_related("user").get(key_hash=key_hash)
except ApiKey.DoesNotExist as exc:
raise self.authentication_failed() from exc
if not api_key.matches_key(raw_key):
raise self.authentication_failed()
if not api_key.is_active_key:
raise PermissionDenied(
api_error("account_disabled", "账号或 API Key 已禁用")
)
if not api_key.user.is_business_active:
raise PermissionDenied(
api_error("account_disabled", "账号或 API Key 已禁用")
)
now = timezone.now()
ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now)
api_key.last_used_at = now
return api_key.user, api_key
def authenticate_header(self, request) -> str:
return self.keyword
@staticmethod
def authentication_failed() -> AuthenticationFailed:
return AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
+7
View File
@@ -0,0 +1,7 @@
def api_error(code: str, message: str) -> dict:
return {
"error": {
"code": code,
"message": message,
}
}
+102 -2
View File
@@ -1,3 +1,103 @@
from django.test import TestCase
import uuid
# Create your tests here.
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import path
from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.api.authentication import ApiKeyAuthentication
from apps.api.views import ExternalApiView
from apps.users.models import ApiKey
class AuthenticatedEchoView(ExternalApiView):
def get(self, request):
return Response(
{
"user_id": request.user.id,
"api_key_id": request.auth.id,
}
)
urlpatterns = [
path("api/test-auth/", AuthenticatedEchoView.as_view()),
]
@override_settings(ROOT_URLCONF=__name__)
class ApiKeyAuthenticationTests(TestCase):
url = "/api/test-auth/"
def setUp(self):
suffix = uuid.uuid4().hex[:8]
self.user = get_user_model().objects.create_user(
username=f"api-user-{suffix}",
email=f"api-user-{suffix}@example.com",
password="password",
)
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="test")
self.client = APIClient()
def auth_header(self, raw_key: str | None = None) -> dict:
return {"HTTP_AUTHORIZATION": f"Bearer {raw_key or self.raw_key}"}
def test_external_api_view_only_uses_api_key_authentication(self):
self.assertEqual(AuthenticatedEchoView.authentication_classes, (ApiKeyAuthentication,))
def test_valid_bearer_key_authenticates_user_and_api_key(self):
response = self.client.get(self.url, **self.auth_header())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["user_id"], self.user.id)
self.assertEqual(response.data["api_key_id"], self.api_key.id)
self.api_key.refresh_from_db()
self.assertIsNotNone(self.api_key.last_used_at)
def test_missing_api_key_returns_401(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
self.assertEqual(response.data["error"]["code"], "unauthorized")
def test_invalid_api_key_returns_401(self):
response = self.client.get(self.url, **self.auth_header("sk_cmhub_invalid"))
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
self.assertEqual(response.data["error"]["code"], "unauthorized")
def test_malformed_authorization_header_returns_401(self):
response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Token {self.raw_key}")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data["error"]["code"], "unauthorized")
def test_revoked_api_key_returns_403(self):
self.api_key.status = ApiKey.Status.REVOKED
self.api_key.save(update_fields=("status", "updated_at"))
response = self.client.get(self.url, **self.auth_header())
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data["error"]["code"], "account_disabled")
def test_disabled_user_returns_403(self):
self.user.status = self.user.Status.DISABLED
self.user.save(update_fields=("status",))
response = self.client.get(self.url, **self.auth_header())
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data["error"]["code"], "account_disabled")
def test_web_session_login_is_not_accepted_for_external_api(self):
self.client.force_login(self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data["error"]["code"], "unauthorized")
+15 -2
View File
@@ -1,3 +1,16 @@
from django.shortcuts import render
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
# Create your views here.
from apps.api.authentication import ApiKeyAuthentication
from apps.api.errors import api_error
class ExternalApiView(APIView):
authentication_classes = (ApiKeyAuthentication,)
permission_classes = (IsAuthenticated,)
def permission_denied(self, request, message=None, code=None):
if request.authenticators and not request.successful_authenticator:
raise AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
super().permission_denied(request, message=message, code=code)
+2 -2
View File
@@ -38,14 +38,14 @@
## 当前阶段
当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-203:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地。下一步进入 T-301 API Key 鉴权。
当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权已落地。下一步进入 T-302 生成标题 / 图片接口。
优先路径:
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。
3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。
4. Phase 3:对外 API 与充值 —— 下一步 T-301 Key 鉴权,随后生成接口、余额查询、充值回调、扫码下单与轮询。
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权已完成;下一步 T-302 生成接口,随后余额查询、充值回调、扫码下单与轮询。
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
+2
View File
@@ -36,6 +36,8 @@
- 编排单次调用:调用计费层预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录。
- 不直接写点数余额字段,必须走计费层提供的方法。
T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer <API_KEY>`,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。
**计费层(`apps/billing`)**
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
+1 -1
View File
@@ -50,7 +50,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- |
| T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | TODO |
| T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | DONE |
| T-302 | 生成标题 / 图片接口 | T-104, T-203, T-301 | 按 `api.md` 实现;请求传**能力别名**+ `parameters`,但 `parameters` 只能经 Provider 白名单透传,核心/计费字段不可被覆盖;编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;评估 `Provider.capabilities()` 与模型声明能力的二次校验;`images_edits` 缺原图 / `AiCapabilityError` 翻译为 400,不落 500;调用记录不保存 provider `raw` / base64;点数不足返回 402;含测试;建议随本任务或 T-403 前跑一次真实图片生成并记录真实耗时 | TODO |
| T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | TODO |
| T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | TODO |
+2
View File
@@ -17,6 +17,8 @@
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
- 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。
T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer <API_KEY>`;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。
通用错误响应:
```json
+9 -9
View File
@@ -12,16 +12,16 @@
## 当前快照
- 日期:2026-07-02
- 阶段:Phase 2 计费核心已完成到 T-204;下一步进入 Phase 3 的 T-301 API Key 鉴权
- 阶段:Phase 3 对外 API 与充值起步;T-301 API Key 鉴权已完成,下一步 T-302 生成标题 / 图片接口
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund
- 测试:T-204 已补齐完整全量测试证据。直接验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`migrate`、`showmigrations billing`、新增直写约束测试、`manage.py test apps.billing --noinput --keepdb`(16 tests OK)、`manage.py test apps.users apps.billing --noinput --keepdb --verbosity 2`(18 tests OK)、`manage.py test apps.ai --noinput --keepdb --verbosity 2`(26 tests OK)、`py -3.12 manage.py test --noinput --keepdb --verbosity 2`(44 tests OK)、`compileall apps`、`git diff --check`(仅 Windows CRLF 提示)、`./init.ps1`。远程 MySQL 仍可能偶发链路超时,必要时先确认无残留测试进程,再串行重跑。
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`
- 测试:T-301 验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py test apps.api --noinput --keepdb --verbosity 2`(8 tests OK)。T-204 已补齐完整全量测试证据:`py -3.12 manage.py test --noinput --keepdb --verbosity 2`(44 tests OK)。远程 MySQL 仍可能偶发链路超时,必要时先确认无残留测试进程,再串行重跑。
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算
- 当前 blocker:无阻塞 T-301 的 blocker。支付商户密钥/证书仍缺真实值,但不阻塞 T-301;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。
- 当前 blocker:无阻塞 T-302 的 blocker。支付商户密钥/证书仍缺真实值,但不阻塞 T-302;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。
## 当前目录要点
@@ -41,10 +41,10 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固。
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权。
- 正在进行:无。
- 当前 blocker:无阻塞 T-301 的 blocker。
- 下一个可领取任务:**T-301 API Key 鉴权(DRF Authentication)**。
- 当前 blocker:无阻塞 T-302 的 blocker。
- 下一个可领取任务:**T-302 生成标题 / 图片接口**。
## 当前可运行内容
@@ -68,14 +68,14 @@ python3.12 manage.py smoke_ai_generation title --recorded
python3.12 manage.py smoke_ai_generation image --recorded
```
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。
## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-301)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-302)。
5. 将该任务状态改为 `DOING`。
## 维护规则
+22
View File
@@ -550,3 +550,25 @@
- 阻塞:无。远程 MySQL 仍可能偶发链路超时;后续测试不要并行跑共享同一 `test_cmhub` 的数据库测试,必要时用 `--keepdb` 串行重跑。
- 决策:T-204 的 P2-2 已补齐单次完整测试全绿证据;并发扣点测试仍必须在 MySQL 上跑,SQLite 不可作为验收依据。
- 下一步:领取 T-301 API Key 鉴权(DRF Authentication)。
## 2026-07-02 T-301 API Key 鉴权(DRF Authentication)
- 状态:DONE
- 变更:
- 新增 `apps/api/authentication.py`:实现 `ApiKeyAuthentication`,只解析 `Authorization: Bearer <API_KEY>`;对明文 Key 做 SHA-256 hash 后查 `ApiKey`,成功后返回 `(user, api_key)`,并更新 `last_used_at`。
- 新增 `apps/api/errors.py`:提供统一错误响应结构 `{"error":{"code":...,"message":...}}`。
- 更新 `apps/api/views.py`:新增 `ExternalApiView`,外部 API 只挂 `ApiKeyAuthentication + IsAuthenticated`,缺失/无效 Key 返回 `401 unauthorized`,不接受 Web session。
- 更新 `apps/api/tests.py`:覆盖有效 Bearer Key、缺失 Key、无效 Key、错误鉴权 scheme、Key revoked、User disabled、Web session 不被外部 API 接受。
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`:T-301 完成,下一步 T-302。
- 验证:
- `./init.ps1`:开工前通过;完成后再次通过。
- `py -3.12 -m py_compile apps\api\authentication.py apps\api\errors.py apps\api\views.py apps\api\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2`:通过,8 tests OK。
- `py -3.12 manage.py makemigrations --check`:通过,No changes detected。
- `py -3.12 -m compileall apps config`:通过。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- `py -3.12 manage.py test --noinput --keepdb --verbosity 2`:通过,52 tests OK,耗时 54.905s。
- 阻塞:无。
- 决策:生成/余额等对外 API 后续统一继承 `ExternalApiView`;充值下单/状态查询仍按用户端 session 流程另行实现,不使用该外部 API 基类。
- 下一步:领取 T-302 生成标题 / 图片接口。