diff --git a/README.md b/README.md index a1c946a..9d659a9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 2 计费核心已完成,T-301 API Key 鉴权与 T-302 生成标题 / 图片接口已落地:对外 API 可用 `Authorization: Bearer ` 调用并按点数预扣、成功确认、失败退点。下一步是 T-303 余额查询接口。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口与 T-303 余额查询接口已落地:对外 API 可用 `Authorization: Bearer ` 调用生成能力或查询点数余额。下一步是 T-304 充值回调。详见 [`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) 第四节计费时序。 diff --git a/apps/api/tests.py b/apps/api/tests.py index 54a40b0..417e832 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -120,6 +120,77 @@ class ApiKeyAuthenticationTests(TestCase): self.assertEqual(response.data["error"]["code"], "unauthorized") +class BalanceApiTests(TestCase): + url = "/api/v1/balance" + + def setUp(self): + suffix = uuid.uuid4().hex[:8] + self.user = get_user_model().objects.create_user( + username=f"balance-user-{suffix}", + email=f"balance-user-{suffix}@example.com", + password="password", + ) + self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="balance") + 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_balance_returns_wallet_balance_matching_ledger_sum(self): + UserWallet.objects.create(user=self.user, points_balance=100) + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.RECHARGE, + points_delta=120, + balance_after=120, + ref_order_id=1, + ) + call = CallRecord.objects.create( + user=self.user, + api_key=self.api_key, + operation_type=CallRecord.OperationType.TITLE, + alias="title-standard", + model_used="gpt-5.5", + points_cost=20, + status=CallRecord.Status.SUCCESS, + ) + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.CONSUME, + points_delta=-20, + balance_after=100, + ref_call=call, + ) + + response = self.client.get(self.url, **self.auth_header()) + + ledger_sum = sum( + PointsLedger.objects.filter(user=self.user).values_list( + "points_delta", + flat=True, + ) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["user"], self.user.username) + self.assertEqual(response.data["points_balance"], 100) + self.assertEqual(response.data["points_balance"], ledger_sum) + + def test_balance_returns_zero_without_creating_missing_wallet(self): + response = self.client.get(self.url, **self.auth_header()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["points_balance"], 0) + self.assertFalse(UserWallet.objects.filter(user=self.user).exists()) + + def test_balance_does_not_accept_web_session_without_api_key(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") + + class FakeGenerationProvider: def __init__(self, *, capabilities=None): self._capabilities = set(capabilities or {"text", "image", "vision"}) diff --git a/apps/api/urls.py b/apps/api/urls.py index 88e3794..eb6041e 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from .views import GenerateImageView, GenerateTitleView +from .views import BalanceView, GenerateImageView, GenerateTitleView urlpatterns = [ + path("v1/balance", BalanceView.as_view(), name="api-balance"), path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"), ] diff --git a/apps/api/views.py b/apps/api/views.py index bbdb4ae..b2f15c7 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -15,6 +15,7 @@ from apps.api.serializers import ( GenerateImageRequestSerializer, GenerateTitleRequestSerializer, ) +from apps.billing.services import get_balance_snapshot class ExternalApiView(APIView): @@ -64,3 +65,15 @@ class GenerateImageView(ExternalApiView): except ApiRequestError as exc: return Response(exc.as_response_data(), status=exc.http_status) return Response(data, status=status.HTTP_200_OK) + + +class BalanceView(ExternalApiView): + def get(self, request): + balance = get_balance_snapshot(request.user) + return Response( + { + "user": request.user.get_username(), + "points_balance": balance.points_balance, + }, + status=status.HTTP_200_OK, + ) diff --git a/apps/billing/services.py b/apps/billing/services.py index b5c9591..a886819 100644 --- a/apps/billing/services.py +++ b/apps/billing/services.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from django.db import transaction +from django.db.models import Sum from apps.users.models import UserWallet @@ -46,6 +47,12 @@ class RefundResult: refunded: bool +@dataclass(frozen=True) +class BalanceSnapshot: + points_balance: int + ledger_balance: int + + def _validate_positive_points(points: int) -> int: if isinstance(points, bool) or not isinstance(points, int) or points <= 0: raise ValueError("points must be a positive integer") @@ -57,6 +64,21 @@ def _locked_wallet_for_user(user) -> UserWallet: return wallet +def get_balance_snapshot(user) -> BalanceSnapshot: + points_balance = ( + UserWallet.objects.filter(user=user) + .values_list("points_balance", flat=True) + .first() + ) + ledger_balance = PointsLedger.objects.filter(user=user).aggregate( + total=Sum("points_delta") + )["total"] or 0 + return BalanceSnapshot( + points_balance=int(points_balance or 0), + ledger_balance=int(ledger_balance), + ) + + def precharge_call( *, user, diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 1b9c394..77b0de7 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,14 +38,14 @@ ## 当前阶段 -当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权与 T-302 生成标题 / 图片接口已落地。下一步进入 T-303 余额查询接口。 +当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权、T-302 生成标题 / 图片接口与 T-303 余额查询接口已落地。下一步进入 T-304 充值回调。 优先路径: 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 鉴权、T-302 生成接口已完成;下一步 T-303 余额查询,随后充值回调、扫码下单与轮询。 +4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询已完成;下一步 T-304 充值回调,随后扫码下单与轮询。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 1e4cd36..d0458da 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -40,6 +40,8 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。 +T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。 + **计费层(`apps/billing`)** - 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index e3e6578..c3da716 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -52,7 +52,7 @@ | --- | --- | --- | --- | --- | | 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 前跑一次真实图片生成并记录真实耗时 | DONE | -| T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | TODO | +| T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | DONE | | T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | TODO | | T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | TODO | diff --git a/docs/api.md b/docs/api.md index 68d0bdb..c093476 100644 --- a/docs/api.md +++ b/docs/api.md @@ -21,6 +21,8 @@ T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthenti T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。 +T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。 + 通用错误响应: ```json diff --git a/docs/current-state.md b/docs/current-state.md index 6536309..22dfca1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,16 +12,16 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:Phase 3 对外 API 与充值起步;T-302 生成标题 / 图片接口已完成,下一步 T-303 余额查询接口 +- 阶段:Phase 3 对外 API 与充值起步;T-303 余额查询接口已完成,下一步 T-304 充值回调 - 技术栈:系统 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-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由 -- 测试:T-302 验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py test apps.api --noinput --keepdb --verbosity 2`(16 tests OK)、`py -3.12 manage.py test --noinput --keepdb --verbosity 2`(60 tests OK)。远程 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-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口 +- 测试:T-303 验证通过:`./init.ps1`、`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py test apps.api --noinput --keepdb --verbosity 2`(19 tests OK)、`py -3.12 manage.py test apps.ai.tests.AiModelEncryptionTests --noinput --keepdb --verbosity 2`(2 tests OK,使用 `MYSQL_CONNECT_TIMEOUT=90` 补跑连接失败类)。完整 `py -3.12 manage.py test --noinput --keepdb --verbosity 2` 多次尝试仍被远程 MySQL `43.128.3.240:3306` 连接超时 / reset 阻断;已通过用例无断言失败,失败点是建连接或测试类 setUp。 - 数据: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-303 的 blocker。支付商户密钥/证书仍缺真实值,但不阻塞 T-303;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。 +- 当前 blocker:远程 MySQL 连接当前不稳定,导致完整测试暂未取得 T-303 后的单次全绿;T-303 相关测试已通过。支付商户密钥/证书仍缺真实值,但可先按 T-304 mock / 可替换配置实现;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。 ## 当前目录要点 @@ -33,7 +33,7 @@ | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | -| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203 已新增 `apps/billing/services.py`;T-301/T-302 已在 api 落鉴权、生成接口编排、序列化器、图片存储与路由 | +| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303 已在 `apps/billing/services.py` 落扣点/退点与余额快照;T-301/T-302/T-303 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询与路由 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -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-301 API Key 鉴权;T-302 生成标题 / 图片接口。 +- 已完成: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 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口。 - 正在进行:无。 -- 当前 blocker:无阻塞 T-303 的 blocker。 -- 下一个可领取任务:**T-303 余额查询接口**。 +- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书仍待提供。 +- 下一个可领取任务:**T-304 充值回调(微信/支付宝验签 + 幂等入账)**。 ## 当前可运行内容 @@ -68,19 +68,20 @@ python3.12 manage.py smoke_ai_generation title --recorded python3.12 manage.py smoke_ai_generation image --recorded ``` -当前对外生成接口: +当前对外 API: - `POST /api/v1/generate/title` - `POST /api/v1/generate/image` +- `GET /api/v1/balance` -当前骨架可运行。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 ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。 +当前骨架可运行。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 ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。 ## 开始编码前检查 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-303)。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-304)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/progress.md b/progress.md index 8552fda..d45f741 100644 --- a/progress.md +++ b/progress.md @@ -595,3 +595,27 @@ - 阻塞:无。真实上游标题/图片接口未跑,原因是当前环境未配置可用 `AI_KEY_ENCRYPTION_KEY` + AiModel/ModelAlias + 真实上游 key;图片同步真实耗时风险仍保留到 T-403 部署前校准。 - 决策:MVP 图片结果先用本地 media 存储返回 URL,不引入 S3 依赖;后续生产对象存储可替换 `default_storage`。 - 下一步:领取 T-303 余额查询接口。 + +## 2026-07-02 T-303 余额查询接口 + +- 状态:DONE +- 变更: + - `apps/billing/services.py`:新增 `BalanceSnapshot` 与 `get_balance_snapshot(user)`,只读返回 `UserWallet.points_balance` 与 `PointsLedger.points_delta` 累加值,供余额接口和后续对账复用。 + - `apps/api/views.py`:新增 `BalanceView`,继承 `ExternalApiView`,只接受 API Key 鉴权,返回当前用户名与 `points_balance`。 + - `apps/api/urls.py`:新增 `GET /api/v1/balance` 路由。 + - `apps/api/tests.py`:新增余额接口测试,覆盖余额响应与流水累加一致、缺失钱包返回 0 且不创建钱包、Web session 不能调用外部余额接口。 + - 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`:T-303 完成,下一步 T-304。 +- 验证: + - `./init.ps1`:开工前通过,依赖同步与 `manage.py check` 正常。 + - `py -3.12 -m py_compile apps\billing\services.py apps\api\views.py apps\api\urls.py apps\api\tests.py`:通过。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2`:通过,19 tests OK。 + - `py -3.12 manage.py test apps.ai.tests.AiModelEncryptionTests --noinput --keepdb --verbosity 2`(设置 `MYSQL_CONNECT_TIMEOUT=90` 补跑失败类):通过,2 tests OK。 + - `py -3.12 -m compileall apps config`:通过。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 + - `Test-NetConnection 43.128.3.240 -Port 3306`:一次超时,后续通过,`TcpTestSucceeded=True`。 + - `py -3.12 manage.py test --noinput --keepdb --verbosity 2`:多次未作为绿灯;一次工具超时未取得明细,后续运行分别在 `apps.users.tests.AdminSmokeTests.setUpClass` / `apps.ai.tests.AiModelEncryptionTests.setUpClass` / `apps.billing.tests.ConcurrentDebitTests.setUp` 或测试库 setup 阶段遇到远程 MySQL 连接超时或 reset;已跑过的测试均无断言失败。 +- 阻塞:T-303 功能与相关测试无阻塞;完整测试当前被远程 MySQL `43.128.3.240:3306` 连接稳定性阻塞,后续建议数据库连通稳定后重跑一次 `py -3.12 manage.py test --noinput --keepdb --verbosity 2`。 +- 决策:余额接口返回 `UserWallet.points_balance` 作为对外余额;测试用流水累加值校验账务场景一致性。接口只读,不在 API 层创建或修改钱包。 +- 下一步:领取 T-304 充值回调(微信/支付宝验签 + 幂等入账)。