Commit aef9e37c by 沈彬

feat(account): 账户层级与分桶限额、分账月账、对外接口文档,并修复审查三项与并发对账竞态

应用(app/)
- 新增 account_admission.py:可用性查询与真实调用准入共用同一套主体判定(账户状态、网关绑定、
  赠送门闩、未核清调用、主体级联停用、生效额度、当月用量、扣费桶余额)
- 号段分配前移到业务锁之前(_mutate / 开户 provisioning / 赠送入账),发号器改用独立连接池
  (db_generator_pool_size、db_generator_max_overflow;web 与 worker 双装配)
- 凭证列表改分页 + status/subAccountId/scopeId 筛选,回执补 scopeId/subAccountId/scopeType
- 并发同 requestId 对账:命中重放时重读业务行、版本不一致时在新事务复查幂等行,
  消除"已成功却返回过期 version/phase"与误判版本冲突
- 门店/员工 scope 与员工自然月额度、分账维度索引、月账单直读、超额告警

测试(tests/)
- 新增:review_findings_repro(三项修复护栏)、test_account_scope、test_account_sub_account、
  test_account_usage_month、test_account_ledger_scope、test_account_overage_alerts、
  test_account_scope_e2e
- test_account_gifts 补并发对账重放护栏用例

文档(docs/、README)
- external-api-reference.md(35 个操作全覆盖,路由自应用导出机械校验)、external-api-flow.html(含 SSE)
- architecture-code-quality-risks-20260930.md(含修复记录 §8);设计文档 / p0 契约 / 开发计划同步
- DDL 与对账 SQL 归档至 scripts/sql/,新增 scripts/p1_mysql_instance.sh 隔离 MySQL 实例脚本

回归(python3 -m pytest -q -W error::sqlalchemy.exc.SAWarning)
- 隔离 MySQL 8.0.43:2211 用例 / 2181 通过 / 0 失败 / 30 跳过 / 207s
- SQLite:2211 用例 / 1677 通过 / 0 失败 / 534 跳过 / 69s
parent f12d2992
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
P1 数据底座、P2 开户/凭证、P3 赠送/账单与 P4 模型调用/结算补偿已完成开发和本地验证,目标环境验收待补。独立入口提供固定角色权限、开户恢复、凭证生命周期、来源授权、账户停启用、精确赠送、受控核清、账单查询、模型调用与租约补偿;P5 起的数据导入/切流未开始。 P1 数据底座、P2 开户/凭证、P3 赠送/账单与 P4 模型调用/结算补偿已完成开发和本地验证,目标环境验收待补。独立入口提供固定角色权限、开户恢复、凭证生命周期、来源授权、账户停启用、精确赠送、受控核清、账单查询、模型调用与租约补偿;P5 起的数据导入/切流未开始。
- `app/account_models.py`:11 张 `t_computing_*` 独立表,无 SaaS 主数据依赖;数据库默认时间使用 UTC 毫秒表达式,不依赖导入会话时区。 - `app/account_models.py`:15 张 `t_computing_*` 独立表(含子账号、门店/员工及月额度),无 SaaS 主数据依赖;数据库默认时间使用 UTC 毫秒表达式,不依赖导入会话时区。
- `app/account_repository.py`:账户/来源作用域查询、账户锁和未清调用查询。 - `app/account_repository.py`:账户/来源作用域查询、账户锁和未清调用查询。
- `app/db.py:create_mysql_engine`:显式 READ COMMITTED、UTC、严格模式;由调用方显式传入连接,尚未改部署配置。 - `app/db.py:create_mysql_engine`:显式 READ COMMITTED、UTC、严格模式;由调用方显式传入连接,尚未改部署配置。
- `app/id_generator.py`:新库调用方显式选择独立 IdSegment;种子只向前推进,fork 子进程重建发号锁并丢弃余段。调用方必须在子进程内创建 Engine/Session 工厂,不复用父进程连接池或 Session。 - `app/id_generator.py`:新库调用方显式选择独立 IdSegment;种子只向前推进,fork 子进程重建发号锁并丢弃余段。调用方必须在子进程内创建 Engine/Session 工厂,不复用父进程连接池或 Session。
...@@ -17,7 +17,7 @@ P1 数据底座、P2 开户/凭证、P3 赠送/账单与 P4 模型调用/结算 ...@@ -17,7 +17,7 @@ P1 数据底座、P2 开户/凭证、P3 赠送/账单与 P4 模型调用/结算
`app/account_main.py` 是独立入口,仅加载新的账户接口,不存在 auth_mode/disabled 或可信 X-App-Id。默认监听 loopback;实际管理端网络隔离、TLS/受控加密通道和外置文件权限由部署方配置,本轮未修改。 `app/account_main.py` 是独立入口,仅加载新的账户接口,不存在 auth_mode/disabled 或可信 X-App-Id。默认监听 loopback;实际管理端网络隔离、TLS/受控加密通道和外置文件权限由部署方配置,本轮未修改。
外置 JSON 文件必填 `database_url`、`newapi_base_url`(HTTPS)、`newapi_pat`、`newapi_model`;可选 `db_pool_size`、`db_max_overflow`、`management_timeout_seconds`、`management_concurrency`、`connect_timeout_seconds`、`read_timeout_seconds`。不从环境变量补值,未知字段或缺失配置拒绝启动,真实配置文件必须位于项目目录外。 外置 JSON 文件必填 `database_url`、`newapi_base_url`(HTTPS)、`newapi_pat`、`newapi_model`;可选 `db_pool_size`、`db_max_overflow`、`db_generator_pool_size`、`db_generator_max_overflow`、`management_timeout_seconds`、`management_concurrency`、`connect_timeout_seconds`、`read_timeout_seconds`。发号器用独立小池(默认 2+2):号段分配不占用业务连接池,写事务不会为取号去抢自己的连接。不从环境变量补值,未知字段或缺失配置拒绝启动,真实配置文件必须位于项目目录外。
```bash ```bash
python -m app.account_main --config /path/outside-project/account.json python -m app.account_main --config /path/outside-project/account.json
...@@ -57,23 +57,25 @@ P3 复用已有物理表,无新增 SQL,无需重跑建表;未修改真实 ...@@ -57,23 +57,25 @@ P3 复用已有物理表,无新增 SQL,无需重跑建表;未修改真实
## 文档 ## 文档
- **[对外接口文档](docs/external-api-reference.md)**:给外部接入方(SaaS/B 端)的完整接口参考——鉴权、幂等、错误码、35 个操作、层级与限额语义、对账口径、对接纪律。**对外交付用这一份**。
- **[接入全链路流程图](docs/external-api-flow.html)**:开户→子账号→门店/员工→签发 Key→调用(含 SSE 与失败分支)可视化,附 OpenAI 协议符合性矩阵。可独立打开或随文档交付。
- [P0 契约](docs/p0-contract-freeze.md):字段、状态、权限与幂等域,含 P1–P4 细化。 - [P0 契约](docs/p0-contract-freeze.md):字段、状态、权限与幂等域,含 P1–P4 细化。
- [账户层级与门店/员工限额设计](docs/account-hierarchy-and-scope-limits-design.md):段 1–3 的设计与取舍(子账号/门店/员工/额度/分账/月账单)。
- [独立化开发计划](docs/independent-account-development-plan.md):P0–P8、验收、迁移和单写切流。 - [独立化开发计划](docs/independent-account-development-plan.md):P0–P8、验收、迁移和单写切流。
- [旧设计](docs/design.md)、[旧 RSA 协议](docs/authentication.md):历史参考,商户耦合和公网路线已被取代。 - [旧设计](docs/design.md)、[旧 RSA 协议](docs/authentication.md):历史参考,商户耦合和公网路线已被取代。
## P1 SQL 交付 ## SQL 文件与建库
全部脚本由用户审阅并手动执行。本轮未连接业务数据库,未执行迁移、授予权限或设置真实配置。 系统仍在开发、尚未上线。SQL 统一放在 [`scripts/sql/`](scripts/sql/README.md),新环境只使用当前全量基线,不再叠加历史增量。
| 文件 | 执行位置与用途 | | 文件 | 执行位置与用途 |
| --- | --- | | --- | --- |
| `scripts/20260923_independent_account_ddl.sql` | 新独立 schema,创建 11 张表;不建库、不改权限、不导入数据、不初始化号段 | | `scripts/sql/schema.sql` | 空的独立算力库,创建当前 15 张表及索引、约束;由 ORM 生成 |
| `scripts/20260923_account_source_precheck.sql` | 旧 SaaS 库,只读检查实际结构、来源、孤儿关联、余额、未清状态与高水位 | | `scripts/sql/check.sql` | 当前独立库,只读核对账本、余额桶、员工月用量、关联和号段 |
| `scripts/20260923_account_target_check.sql` | 新独立库,只读检查余额、调用/消费/流水关联、门闩和号段 | | `scripts/sql/saas/` | SaaS 接入/迁移材料,按实际对接方案单独核对;不随网关初始化执行 |
| `scripts/sql/archive/` | 历史基线、增量及旧检查原文,仅供追溯 |
MySQL 最低 8.0.16,目标版本上线前须确认。DDL 不使用 IF NOT EXISTS,部分失败必须先检查,不使用客户端 `--force` 继续执行。迁移需停旧分配器,复制旧已提交高水位;只有真正空的新安装才允许从 100000000000000000 初始化。不能用 MAX(id)+1,不能在未知数据情况下直接执行低位种子。 建库、号段初始化、结果判读和开发变更流程见 [SQL 使用说明](scripts/sql/README.md)。最低 MySQL 8.0.16;全量 DDL 不用于升级已有数据库,不使用 `--force` 跳过错误。脚本不自动建库、授权、导入数据或重置号段。
本阶段不提供自动导入/一键切流;实际历史数据和 SaaS 映射必须先经过源库预检。已经执行过的 `20260922_computing_stage0_ddl.sql` 不改动。
## 开发验证 ## 开发验证
...@@ -81,10 +83,10 @@ MySQL 最低 8.0.16,目标版本上线前须确认。DDL 不使用 IF NOT EXIS ...@@ -81,10 +83,10 @@ MySQL 最低 8.0.16,目标版本上线前须确认。DDL 不使用 IF NOT EXIS
```bash ```bash
python -m pytest -q python -m pytest -q
python scripts/render_account_ddl.py --output scripts/20260923_independent_account_ddl.sql python scripts/render_account_ddl.py --output scripts/sql/schema.sql
``` ```
可选 `--account-mysql-socket=<临时实例socket>` 验证真实 MySQL;测试仅接受父目录名以 `computing-p1-mysql.` 开头的专用实例,为每个测试创建随机独立库并清理,不应指向已有业务实例。未提供时 MySQL 分支跳过,SQLite 结果不能替代行锁验收。 可选 `--account-mysql-socket=<临时实例socket>` 验证真实 MySQL;测试仅接受父目录名以 `computing-p1-mysql.` 开头的专用实例,为每个测试创建随机独立库并清理,不应指向已有业务实例。未提供时 MySQL 分支跳过,SQLite 结果不能替代行锁验收。`scripts/p1_mysql_instance.sh start|stop|status` 负责起停这套隔离实例(unix socket、不开端口、READ COMMITTED + UTC + 严格模式),start 会打印可直接复制的回归命令。
2026-09-23 本地结果:Python 3.9.6 + 无网络监听的临时 MySQL 9.6.0,168 项通过、14 项跳过(均为 SQLite 不适用的 MySQL 专项分支,对应 MySQL 分支通过)。MySQL 测试直接执行交付 DDL,覆盖三元幂等、关联约束、UTC 默认时间、账户行锁、并发号段与持锁 fork、负余额及目标对账异常。Python 3.12、实际部署 MySQL 版本与运行账号无 SaaS 权限仍需补验,不能据此认定 P1 最终验收或迁移上线通过。 2026-09-23 本地结果:Python 3.9.6 + 无网络监听的临时 MySQL 9.6.0,168 项通过、14 项跳过(均为 SQLite 不适用的 MySQL 专项分支,对应 MySQL 分支通过)。MySQL 测试直接执行交付 DDL,覆盖三元幂等、关联约束、UTC 默认时间、账户行锁、并发号段与持锁 fork、负余额及目标对账异常。Python 3.12、实际部署 MySQL 版本与运行账号无 SaaS 权限仍需补验,不能据此认定 P1 最终验收或迁移上线通过。
......
"""调用主体准入判定:可用性查询与真实调用共用同一套规则。
修复前 `AccountService._account_view`(可用性)与 `CallService._admit`(调用准入)各写一套:
可用性只读账户行(含**账户未分配桶**余额),准入按 Key 主体取桶(子账号桶、门店/员工状态、
员工月度额度),于是同一个 Key 会得到互相矛盾的结论:子账号没余额但总账号有余额时返回
available=true、调用失败;反过来返回不可用、调用成功。
这里把判定抽成一份实现,可用性与准入共用。判定顺序与调用准入原实现保持一致,错误码与
data 不变:
账户状态 → 网关绑定(由调用侧传入)→ 赠送门 → 未决调用 → 主体(门店/员工)→ 扣费桶余额
两侧的区别只在**取桶方式**:调用侧 `for_update=True`(锁桶后再扣费),可用性查询走普通读。
"""
from dataclasses import dataclass, field
from typing import List, Optional
from . import account_repository as repository
from .account_security import AccountError
from .billing import EXPECTED_QUOTA_PER_UNIT
ACCOUNT_BUCKET = "ACCOUNT"
SUB_ACCOUNT_BUCKET = "SUB_ACCOUNT"
@dataclass
class Admission:
"""判定结果:`reasons` 供 `blockedReasons` 出参,`error` 供调用侧直接抛。"""
reasons: List[str] = field(default_factory=list)
error: Optional[AccountError] = None
bucket_type: str = ACCOUNT_BUCKET
bucket_id: Optional[int] = None
balance_point_units: int = 0
@property
def allowed(self) -> bool:
return not self.reasons
def _block(self, reason, error):
self.reasons.append(reason)
if self.error is None:
self.error = error
return self
def subject_of(session, principal, month):
"""解析凭证绑定的门店/员工主体;主体悬空按停用处理(与调用准入一致)。"""
if principal.scope_id is None:
return None
subject = repository.resolve_call_subject(session, principal.scope_id, month)
if subject is None or subject.account_id != principal.account_id:
raise AccountError("SCOPE_DISABLED", data={"scopeId": str(principal.scope_id)})
return subject
def subject_bucket(subject):
"""主体扣费桶:门店用自身归属(NULL = 直接落在账户未分配桶),员工走当前门店。"""
return subject.store_bucket if subject.scope_type == "STORE" else subject.parent_bucket
def _judge_subject(subject, month):
"""门店/员工主体判定,返回 (reason, error);无阻断时返回 (None, None)。"""
if subject.scope_type == "STORE":
if subject.scope_status != "ACTIVE":
return "SCOPE_DISABLED", AccountError("SCOPE_DISABLED",
data={"scopeId": str(subject.scope_id)})
return None, None
# 员工只经当前门店可达:门店停用即全员下线。
if (subject.scope_status != "ACTIVE" or subject.parent_id is None
or subject.parent_type != "STORE"):
return "SCOPE_DISABLED", AccountError("SCOPE_DISABLED",
data={"scopeId": str(subject.scope_id)})
if subject.parent_status != "ACTIVE":
return "SCOPE_DISABLED", AccountError("SCOPE_DISABLED",
data={"scopeId": str(subject.scope_id),
"parentScopeId": str(subject.parent_id)})
quota = subject.current_quota
if quota is not None and subject.used_point_units >= quota:
return "EMPLOYEE_QUOTA_EXCEEDED", AccountError("EMPLOYEE_QUOTA_EXCEEDED", data={
"scopeId": str(subject.scope_id), "quotaMonth": month,
"limitPointUnits": str(quota), "usedPointUnits": str(subject.used_point_units)})
return None, None
def binding_error(account, settings, model=None):
"""网关绑定校验:provision 状态、令牌、网关身份、计量口径(可选再校验请求模型)。
调用侧传具体模型;可用性查询只校验"账户已绑定可用令牌且计量口径一致",因此查询侧
不传 model —— 两边用的是同一条规则。
"""
snapshot = account.gateway_snapshot or {}
if (account.provision_status != "READY" or not account.gateway_token_id
or not account.gateway_token_name
or snapshot.get("gatewayId") != settings.gateway_identity
or snapshot.get("quotaPerUnit") != EXPECTED_QUOTA_PER_UNIT):
return AccountError("DEPENDENCY_UNAVAILABLE")
if model is None:
return None if snapshot.get("model") else AccountError("DEPENDENCY_UNAVAILABLE")
return None if snapshot.get("model") == model else AccountError("DEPENDENCY_UNAVAILABLE")
def evaluate(session, account, *, principal=None, sub_account_id=None, subject=None, month=None,
for_update=False, uncertain=None, binding_error=None):
"""按调用主体口径判定账户能否调用,并给出真正扣费的那个桶及其余额。
`binding_error` 是调用侧算好的网关绑定错误(`_admit` 在账户状态之后、赠送门之前
校验绑定,顺序保持原样);可用性查询不需要绑定校验,传 None。
`principal` 供查询侧直接传凭证主体:内部解析门店/员工,主体悬空收成
SCOPE_DISABLED 原因(查询不该因为主体悬空直接报错)。
"""
admission = Admission()
subject_error = None
if principal is not None:
if sub_account_id is None:
sub_account_id = principal.sub_account_id
if subject is None and principal.scope_id is not None:
try:
subject = subject_of(session, principal, month)
except AccountError as exc:
subject_error = exc
if account.status != "ACTIVE":
admission._block("ACCOUNT_DISABLED", AccountError("ACCOUNT_DISABLED"))
if binding_error is not None:
admission._block("DEPENDENCY_UNAVAILABLE", binding_error)
if account.gift_gate is not None:
admission._block("GIFT_GATE_BUSY", AccountError("GIFT_GATE_BUSY"))
if uncertain is None:
uncertain = repository.count_uncertain_calls(session, account.id) > 0
if uncertain:
admission._block("UNRESOLVED_CALL",
AccountError("ACCOUNT_BLOCKED", reason="UNRESOLVED_CALL"))
if subject_error is not None:
admission._block("SCOPE_DISABLED", subject_error)
elif subject is not None:
reason, error = _judge_subject(subject, month)
if reason is not None:
admission._block(reason, error)
bucket_id = subject_bucket(subject) if subject is not None else sub_account_id
admission.bucket_id = bucket_id
if bucket_id is None:
admission.bucket_type = ACCOUNT_BUCKET
admission.balance_point_units = account.balance_point_units
if account.balance_point_units <= 0:
admission._block("INSUFFICIENT_BALANCE",
AccountError("ACCOUNT_BLOCKED", reason="INSUFFICIENT_BALANCE"))
return admission
admission.bucket_type = SUB_ACCOUNT_BUCKET
# A sub-account key never draws from the unallocated bucket; a zero
# unallocated balance on the parent must not block it.
if for_update:
sub = repository.lock_sub_account(session, bucket_id)
else:
sub = repository.find_sub_account(session, account.id, bucket_id)
if sub is None or sub.account_id != account.id:
return admission._block("SUB_ACCOUNT_NOT_FOUND",
AccountError("ACCOUNT_BLOCKED", reason="SUB_ACCOUNT_NOT_FOUND"))
admission.balance_point_units = sub.balance_point_units
if sub.status != "ACTIVE":
return admission._block("SUB_ACCOUNT_DISABLED", AccountError("SUB_ACCOUNT_DISABLED"))
if sub.balance_point_units <= 0:
return admission._block("INSUFFICIENT_BALANCE", AccountError(
"ACCOUNT_BLOCKED", reason="INSUFFICIENT_BALANCE", data={"subAccountId": str(sub.id)}))
return admission
...@@ -7,12 +7,13 @@ import anyio ...@@ -7,12 +7,13 @@ import anyio
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from . import account_repository as repository from . import account_repository as repository
from . import account_admission as admission
from .account_config import _valid_model from .account_config import _valid_model
from .account_execution import run_database from .account_execution import run_database
from .account_gateway import GatewayError from .account_gateway import GatewayError
from .account_model_gateway import StreamInterrupted from .account_model_gateway import StreamInterrupted
from .account_models import Call from .account_models import Call
from .account_security import AccountError, authenticate, utc_now, validate_request_id from .account_security import AccountError, authenticate, quota_month, utc_now, validate_request_id
from .account_service import _time, fingerprint from .account_service import _time, fingerprint
from .account_settlement import SettlementService from .account_settlement import SettlementService
from .billing import EXPECTED_QUOTA_PER_UNIT, format_points from .billing import EXPECTED_QUOTA_PER_UNIT, format_points
...@@ -28,6 +29,26 @@ def _string(value): ...@@ -28,6 +29,26 @@ def _string(value):
return str(value) if value is not None else None return str(value) if value is not None else None
def _quota_month(now):
"""Registration month in the business timezone: the monthly usage bucket has
to flip at Beijing midnight, not at UTC midnight."""
return quota_month(now)
def _snapshot_bucket(principal, subject):
if subject is not None:
return admission.subject_bucket(subject)
return principal.sub_account_id
def _snapshot_store(subject):
"""The store a call is attributed to: the store itself for a store key, the
store the employee belonged to at registration for an employee key."""
if subject is None:
return None
return subject.scope_id if subject.scope_type == "STORE" else subject.parent_id
def call_view(row, *, include_content=True): def call_view(row, *, include_content=True):
available = row.result_json is not None and row.create_time > utc_now() - timedelta(days=7) available = row.result_json is not None and row.create_time > utc_now() - timedelta(days=7)
result = json.loads(row.result_json) if available and include_content else None result = json.loads(row.result_json) if available and include_content else None
...@@ -96,21 +117,17 @@ class CallService: ...@@ -96,21 +117,17 @@ class CallService:
"tokenName": account.gateway_token_name, "model": model, "tokenName": account.gateway_token_name, "model": model,
"quotaPerUnit": snapshot.get("quotaPerUnit")} "quotaPerUnit": snapshot.get("quotaPerUnit")}
def _admit(self, session, account, model): def _subject(self, session, principal, month):
if account.status != "ACTIVE": """Resolve the credential's scope, if any, into the calling subject."""
raise AccountError("ACCOUNT_DISABLED") return admission.subject_of(session, principal, month)
def _admit(self, session, account, model, sub_account_id=None, subject=None, month=None):
binding = self._binding(account, model) binding = self._binding(account, model)
if (account.provision_status != "READY" or not binding["tokenId"] or not binding["tokenName"] verdict = admission.evaluate(session, account, sub_account_id=sub_account_id,
or binding["gatewayId"] != self.settings.gateway_identity subject=subject, month=month, for_update=True,
or binding["quotaPerUnit"] != EXPECTED_QUOTA_PER_UNIT binding_error=admission.binding_error(account, self.settings, model))
or (account.gateway_snapshot or {}).get("model") != model): if verdict.error is not None:
raise AccountError("DEPENDENCY_UNAVAILABLE") raise verdict.error
if account.gift_gate is not None:
raise AccountError("GIFT_GATE_BUSY")
if repository.count_uncertain_calls(session, account.id):
raise AccountError("ACCOUNT_BLOCKED", reason="UNRESOLVED_CALL")
if account.balance_point_units <= 0:
raise AccountError("ACCOUNT_BLOCKED", reason="INSUFFICIENT_BALANCE")
return binding return binding
def _state(self, row): def _state(self, row):
...@@ -118,15 +135,20 @@ class CallService: ...@@ -118,15 +135,20 @@ class CallService:
"version": row.version, "binding": row.gateway_error_snapshot["binding"], "version": row.version, "binding": row.gateway_error_snapshot["binding"],
"view": call_view(row)} "view": call_view(row)}
def _prepare(self, secret, request_id, body, deadline, gateway_params=None, stream_options=None): def _prepare(self, secret, request_id, body, deadline=None, gateway_params=None, stream_options=None):
values = {"modelSelection": body.model, "businessCode": body.business_code, values = {"modelSelection": body.model, "businessCode": body.business_code,
"businessRef": body.business_ref, "gatewayParams": gateway_params, "businessRef": body.business_ref, "gatewayParams": gateway_params,
"messages": [message.model_dump() for message in body.messages]} "messages": [message.model_dump() for message in body.messages]}
if stream_options is not None: if stream_options is not None:
values["stream"] = stream_options values["stream"] = stream_options
digest = fingerprint(values)
with self.factory() as session: with self.factory() as session:
principal = self._principal(session, secret) principal = self._principal(session, secret)
if principal.sub_account_id is not None or principal.scope_id is not None:
# Only a keyed subject extends the fingerprint: account-level keys
# keep the exact legacy digest so historical replays stay compatible.
values["subject"] = {"subAccountId": principal.sub_account_id,
"scopeId": principal.scope_id}
digest = fingerprint(values)
original = repository.find_call(session, principal.account_id, principal.client_id, request_id) original = repository.find_call(session, principal.account_id, principal.client_id, request_id)
if original is not None: if original is not None:
if original.fingerprint != digest: if original.fingerprint != digest:
...@@ -147,12 +169,19 @@ class CallService: ...@@ -147,12 +169,19 @@ class CallService:
if (not _valid_model(model) or model != self.settings.newapi_model if (not _valid_model(model) or model != self.settings.newapi_model
or body.business_code is not None and body.business_code not in BUSINESS_CODES): or body.business_code is not None and body.business_code not in BUSINESS_CODES):
raise AccountError("INVALID_ARGUMENT") raise AccountError("INVALID_ARGUMENT")
binding = self._admit(session, account, model) registered_at = utc_now()
month = _quota_month(registered_at)
subject = self._subject(session, principal, month)
binding = self._admit(session, account, model, principal.sub_account_id,
subject=subject, month=month)
remaining = _remaining(deadline) remaining = _remaining(deadline)
row = Call(id=call_id, account_id=principal.account_id, client_id=principal.client_id, row = Call(id=call_id, account_id=principal.account_id, client_id=principal.client_id,
request_id=request_id, fingerprint=digest, requested_model=body.model, request_id=request_id, fingerprint=digest, requested_model=body.model,
model=model, business_code=body.business_code, business_ref=body.business_ref, model=model, business_code=body.business_code, business_ref=body.business_ref,
dispatch_deadline=utc_now() + timedelta(seconds=remaining), sub_account_id=_snapshot_bucket(principal, subject),
scope_id=principal.scope_id, store_scope_id=_snapshot_store(subject),
quota_month=month,
dispatch_deadline=registered_at + timedelta(seconds=remaining),
gateway_error_snapshot={"binding": binding}) gateway_error_snapshot={"binding": binding})
session.add(row) session.add(row)
session.flush() session.flush()
...@@ -175,7 +204,9 @@ class CallService: ...@@ -175,7 +204,9 @@ class CallService:
raise AccountError("REQUEST_NOT_FOUND") raise AccountError("REQUEST_NOT_FOUND")
if row.dispatch_phase != "REGISTERED" or row.billing_status != "PROCESSING" or row.version != state["version"]: if row.dispatch_phase != "REGISTERED" or row.billing_status != "PROCESSING" or row.version != state["version"]:
return None return None
binding = self._admit(session, account, row.model) subject = self._subject(session, principal, row.quota_month)
binding = self._admit(session, account, row.model, row.sub_account_id,
subject=subject, month=row.quota_month)
if binding != state["binding"]: if binding != state["binding"]:
raise AccountError("DEPENDENCY_UNAVAILABLE") raise AccountError("DEPENDENCY_UNAVAILABLE")
_remaining(deadline) _remaining(deadline)
......
...@@ -84,6 +84,8 @@ class AccountSettings: ...@@ -84,6 +84,8 @@ class AccountSettings:
newapi_model: str = field(repr=False) newapi_model: str = field(repr=False)
db_pool_size: int = 5 db_pool_size: int = 5
db_max_overflow: int = 0 db_max_overflow: int = 0
db_generator_pool_size: int = 2
db_generator_max_overflow: int = 2
management_timeout_seconds: float = 30.0 management_timeout_seconds: float = 30.0
management_concurrency: int = 8 management_concurrency: int = 8
connect_timeout_seconds: float = 10.0 connect_timeout_seconds: float = 10.0
...@@ -115,6 +117,11 @@ class AccountSettings: ...@@ -115,6 +117,11 @@ class AccountSettings:
raise ValueError raise ValueError
if type(self.db_max_overflow) is not int or self.db_max_overflow < 0: if type(self.db_max_overflow) is not int or self.db_max_overflow < 0:
raise ValueError raise ValueError
if type(self.db_generator_pool_size) is not int or self.db_generator_pool_size <= 0:
raise ValueError
if (type(self.db_generator_max_overflow) is not int
or self.db_generator_max_overflow < 0):
raise ValueError
if (type(self.management_concurrency) is not int if (type(self.management_concurrency) is not int
or not 0 < self.management_concurrency <= 100): or not 0 < self.management_concurrency <= 100):
raise ValueError raise ValueError
......
...@@ -10,7 +10,10 @@ from starlette.concurrency import run_in_threadpool ...@@ -10,7 +10,10 @@ from starlette.concurrency import run_in_threadpool
from . import account_repository as repository from . import account_repository as repository
from .account_gateway import GatewayError from .account_gateway import GatewayError
from .account_ledger import gift_view from .account_ledger import gift_view
from .account_models import AccountClient, Client, Credential, Gift, ManagementRequest, OperationAudit, PointRecord from .account_models import (
Account, AccountClient, Client, Credential, Gift, ManagementRequest, OperationAudit,
PointRecord,
)
from .account_security import AccountError, Principal, authenticate, utc_now, validate_request_id from .account_security import AccountError, Principal, authenticate, utc_now, validate_request_id
from .account_service import fingerprint, _time from .account_service import fingerprint, _time
from .billing import ( from .billing import (
...@@ -112,6 +115,17 @@ class GiftService: ...@@ -112,6 +115,17 @@ class GiftService:
"operator_note": body.operator_note}) "operator_note": body.operator_note})
for attempt in range(2): for attempt in range(2):
try: try:
# 号段在业务锁之前领取:先只读确认,命中的重放直接回执(不依赖发号器)。
with self.factory() as probe:
account = probe.get(Account, int(body.account_id))
actor = self._platform(probe, secret)
self._target(probe, account, body.client_id, authorized=True)
replayed = repository.find_gift(probe, account.id, body.client_id, request_id)
if replayed is not None:
if replayed.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH")
return self._state(probe, replayed), False
ids = self._ids(2)
with self.factory.begin() as session: with self.factory.begin() as session:
account = repository.lock_account(session, int(body.account_id)) account = repository.lock_account(session, int(body.account_id))
actor = self._platform(session, secret) actor = self._platform(session, secret)
...@@ -121,9 +135,6 @@ class GiftService: ...@@ -121,9 +135,6 @@ class GiftService:
if row.fingerprint != digest: if row.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH") raise AccountError("FINGERPRINT_MISMATCH")
return self._state(session, row), False return self._state(session, row), False
# Replay must not depend on the ID segment; allocate only now
# that a new gift row is actually required.
ids = self._ids(2)
self._admit(session, account, delta * POINT_UNITS_PER_QUOTA) self._admit(session, account, delta * POINT_UNITS_PER_QUOTA)
row = Gift(id=ids[0], account_id=account.id, client_id=body.client_id, row = Gift(id=ids[0], account_id=account.id, client_id=body.client_id,
actor_credential_id=actor.credential_id, request_id=request_id, actor_credential_id=actor.credential_id, request_id=request_id,
...@@ -327,18 +338,44 @@ class GiftService: ...@@ -327,18 +338,44 @@ class GiftService:
def _reconcile_state(self, secret, request_id, gift_id, body): def _reconcile_state(self, secret, request_id, gift_id, body):
with self.factory() as session: with self.factory() as session:
actor = self._platform(session, secret) actor = self._platform(session, secret)
row = session.get(Gift, gift_id) # 幂等行先查、gift 行后读:并发同 requestId 的对账里,另一个事务可能刚提交
if row is None or (row.account_id, row.client_id) != (int(body.account_id), body.client_id): # 了这笔对账,而镜像里 gift 行还是提交前的版本。先读 gift 会把"已成功"的
raise AccountError("REQUEST_NOT_FOUND") # 重放回执配上过期视图(version 与 phase 倒退),这里改为命中重放后重读。
previous = repository.find_management_request(session, actor.client_id, "RECONCILE", request_id) previous = repository.find_management_request(session, actor.client_id, "RECONCILE", request_id)
if previous is not None: if previous is not None:
if previous.fingerprint != self._reconcile_digest(gift_id, body): if previous.fingerprint != self._reconcile_digest(gift_id, body):
raise AccountError("FINGERPRINT_MISMATCH") raise AccountError("FINGERPRINT_MISMATCH")
row = session.get(Gift, gift_id, populate_existing=True)
if row is None or (row.account_id, row.client_id) != (int(body.account_id), body.client_id):
raise AccountError("REQUEST_NOT_FOUND")
return None, dict(self._view(row), operationStatus=previous.status) return None, dict(self._view(row), operationStatus=previous.status)
row = session.get(Gift, gift_id)
if row is None or (row.account_id, row.client_id) != (int(body.account_id), body.client_id):
raise AccountError("REQUEST_NOT_FOUND")
state = self._state(session, row) state = self._state(session, row)
if state["version"] != body.expected_version:
replayed = self._replay_if_committed(gift_id, body, request_id, actor.client_id)
if replayed is not None:
return None, replayed
self._check_reconcile(state, body) self._check_reconcile(state, body)
return state, None return state, None
def _replay_if_committed(self, gift_id, body, request_id, client_id):
"""并发同 requestId 的对账刚提交时的兜底重放。
版本先于本次预检的快照变化、而幂等行在同一提交里,说明重放行已经落库:
在新事务里复查幂等行(提交过就一定能查到),命中则按重放回执返回最新视图,
避免既抛"版本冲突"又给出过期视图。
"""
with self.factory() as fresh:
previous = repository.find_management_request(fresh, client_id, "RECONCILE", request_id)
if previous is None:
return None
if previous.fingerprint != self._reconcile_digest(gift_id, body):
raise AccountError("FINGERPRINT_MISMATCH")
row = fresh.get(Gift, gift_id)
return dict(self._view(row), operationStatus=previous.status)
def _check_reconcile(self, state, body): def _check_reconcile(self, state, body):
if state["version"] != body.expected_version: if state["version"] != body.expected_version:
raise AccountError("ACCOUNT_BLOCKED", reason="VERSION_CONFLICT") raise AccountError("ACCOUNT_BLOCKED", reason="VERSION_CONFLICT")
......
"""Scoped financial projections; never load call bodies or gateway evidence.""" """Scoped financial projections; never load call bodies or gateway evidence."""
from datetime import timedelta, timezone import re
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import and_, func, literal, select, union_all from sqlalchemy import and_, func, literal, select, union_all
from sqlalchemy.orm import load_only from sqlalchemy.orm import aliased, load_only
from .account_models import Account, AccountClient, Call, Consumption, Credential, Gift, OperationAudit from .account_models import (Account, AccountClient, Call, Consumption, Credential, Gift, OperationAudit,
Scope, ScopeMonthUsage, ScopeQuota)
from .account_security import KEY_PATTERN, AccountError, authenticate, require_management from .account_security import KEY_PATTERN, AccountError, authenticate, require_management
from .billing import format_points, units_json from .billing import format_points, units_json
_MONTH_PATTERN = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
def current_month():
"""业务月键:与调用登记同口径(Asia/Shanghai),见 P0 §1 quotaMonth。"""
return datetime.now(timezone.utc).astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m")
def _authenticate(session, secret): def _authenticate(session, secret):
# Retain the projected account so authentication does not fetch its gateway metadata. # Retain the projected account so authentication does not fetch its gateway metadata.
...@@ -60,6 +70,8 @@ def _consumption_view(row): ...@@ -60,6 +70,8 @@ def _consumption_view(row):
"accountId": units_json(row.account_id), "clientId": row.client_id, "accountId": units_json(row.account_id), "clientId": row.client_id,
"requestId": row.request_id, "businessCode": row.business_code, "businessRef": row.business_ref, "requestId": row.request_id, "businessCode": row.business_code, "businessRef": row.business_ref,
"model": row.model, "source": row.source, "legacyRecord": row.legacy_record, "model": row.model, "source": row.source, "legacyRecord": row.legacy_record,
"subAccountId": units_json(row.sub_account_id), "scopeId": units_json(row.scope_id),
"storeScopeId": units_json(row.store_scope_id),
"dispatchPhase": row.dispatch_phase, "executionStatus": row.execution_status, "dispatchPhase": row.dispatch_phase, "executionStatus": row.execution_status,
"billingStatus": row.billing_status, "settled": row.settled_flag, "billingStatus": row.billing_status, "settled": row.settled_flag,
"settlementSource": row.settlement_source, "settlementSource": row.settlement_source,
...@@ -75,12 +87,15 @@ def _consumption_view(row): ...@@ -75,12 +87,15 @@ def _consumption_view(row):
} }
def _require_scope(principal, gifts, account_ids, owner_client_id, client_id): def _require_scope(principal, gifts, account_ids, owner_client_id, client_id, subjects):
if gifts: if gifts:
require_management(principal) require_management(principal)
if subjects:
# 分账维度只存在于消费账本;赠送没有门店/员工/分桶主体。
raise AccountError("INVALID_ARGUMENT")
if principal.category == "CALL": if principal.category == "CALL":
# Even an override equal to the credential's scope must be rejected. # Even an override equal to the credential's scope must be rejected.
if account_ids is not None or owner_client_id is not None or client_id is not None: if account_ids is not None or owner_client_id is not None or client_id is not None or subjects:
raise AccountError("PERMISSION_DENIED") raise AccountError("PERMISSION_DENIED")
elif principal.category == "INTEGRATION": elif principal.category == "INTEGRATION":
if owner_client_id is not None and owner_client_id != principal.client_id: if owner_client_id is not None and owner_client_id != principal.client_id:
...@@ -124,10 +139,12 @@ def _account_scope(session, principal, account_ids, owner_client_id): ...@@ -124,10 +139,12 @@ def _account_scope(session, principal, account_ids, owner_client_id):
return scope return scope
def _filters(model, scope, client_id, start, end, request_id): def _filters(model, scope, client_id, start, end, request_id, subjects=()):
conditions = [model.account_id.in_(scope)] conditions = [model.account_id.in_(scope)]
if client_id is not None: if client_id is not None:
conditions.append(model.client_id == client_id) conditions.append(model.client_id == client_id)
for name, values in subjects:
conditions.append(getattr(model, name).in_(values))
if start is not None: if start is not None:
conditions.append(model.create_time >= start) conditions.append(model.create_time >= start)
if end is not None: if end is not None:
...@@ -137,7 +154,8 @@ def _filters(model, scope, client_id, start, end, request_id): ...@@ -137,7 +154,8 @@ def _filters(model, scope, client_id, start, end, request_id):
return conditions return conditions
def _gifts(scope, client_id, start, end, request_id): def _gifts(scope, client_id, start, end, request_id, subjects=()):
# 赠送没有分账主体:带主体的请求已在 _require_scope 被拒(INVALID_ARGUMENT)。
return select( return select(
Gift.id, Gift.account_id, Gift.client_id, Gift.request_id, Gift.status, Gift.phase, Gift.version, Gift.id, Gift.account_id, Gift.client_id, Gift.request_id, Gift.status, Gift.phase, Gift.version,
Gift.requested_point_units, Gift.quota_delta, Gift.credited_point_units, Gift.requested_point_units, Gift.quota_delta, Gift.credited_point_units,
...@@ -146,7 +164,7 @@ def _gifts(scope, client_id, start, end, request_id): ...@@ -146,7 +164,7 @@ def _gifts(scope, client_id, start, end, request_id):
).where(*_filters(Gift, scope, client_id, start, end, request_id)) ).where(*_filters(Gift, scope, client_id, start, end, request_id))
def _consumptions(scope, client_id, start, end, request_id): def _consumptions(scope, client_id, start, end, request_id, subjects=()):
calls = select( calls = select(
Call.id, Call.id.label("call_id"), Call.consumption_record_id, Call.id, Call.id.label("call_id"), Call.consumption_record_id,
Call.account_id, Call.client_id, Call.request_id, Call.business_code, Call.business_ref, Call.model, Call.account_id, Call.client_id, Call.request_id, Call.business_code, Call.business_ref, Call.model,
...@@ -156,10 +174,11 @@ def _consumptions(scope, client_id, start, end, request_id): ...@@ -156,10 +174,11 @@ def _consumptions(scope, client_id, start, end, request_id):
Call.output_tokens, Call.total_tokens, Call.consumed_quota, Call.point_units_per_quota, Call.output_tokens, Call.total_tokens, Call.consumed_quota, Call.point_units_per_quota,
Call.consumed_point_units, Call.create_time, Call.last_update_time, Call.consumed_point_units, Call.create_time, Call.last_update_time,
Call.complete_time, Consumption.settled_time, Call.complete_time, Consumption.settled_time,
Call.sub_account_id, Call.scope_id, Call.store_scope_id,
).outerjoin(Consumption, and_( ).outerjoin(Consumption, and_(
Consumption.id == Call.consumption_record_id, Consumption.call_id == Call.id, Consumption.id == Call.consumption_record_id, Consumption.call_id == Call.id,
Consumption.account_id == Call.account_id, Consumption.client_id == Call.client_id, Consumption.account_id == Call.account_id, Consumption.client_id == Call.client_id,
)).where(*_filters(Call, scope, client_id, start, end, request_id)) )).where(*_filters(Call, scope, client_id, start, end, request_id, subjects))
legacy = select( legacy = select(
Consumption.id, Consumption.call_id, Consumption.id.label("consumption_record_id"), Consumption.id, Consumption.call_id, Consumption.id.label("consumption_record_id"),
Consumption.account_id, Consumption.client_id, Consumption.request_id, Consumption.account_id, Consumption.client_id, Consumption.request_id,
...@@ -173,17 +192,40 @@ def _consumptions(scope, client_id, start, end, request_id): ...@@ -173,17 +192,40 @@ def _consumptions(scope, client_id, start, end, request_id):
Consumption.point_units_per_quota, Consumption.consumed_point_units, Consumption.point_units_per_quota, Consumption.consumed_point_units,
Consumption.create_time, Consumption.last_update_time, Consumption.create_time, Consumption.last_update_time,
literal(None).label("complete_time"), Consumption.settled_time, literal(None).label("complete_time"), Consumption.settled_time,
Consumption.sub_account_id, Consumption.scope_id, Consumption.store_scope_id,
).where( ).where(
Consumption.legacy_record.is_(True), Consumption.call_id.is_(None), Consumption.legacy_record.is_(True), Consumption.call_id.is_(None),
*_filters(Consumption, scope, client_id, start, end, request_id), *_filters(Consumption, scope, client_id, start, end, request_id, subjects),
) )
return union_all(calls, legacy) return union_all(calls, legacy)
def _page(accounts, secret, *, gifts, account_ids, owner_client_id, client_id, page, size, start, end, request_id): def _id_list(subjects, name):
for column, values in subjects:
if column == name:
return [str(value) for value in values]
return None
def _subject_filters(sub_account_ids, scope_ids, store_scope_ids):
"""Validate the 分账 dimensions once and keep them as (column, ids) pairs."""
subjects = []
for name, values in (("sub_account_id", sub_account_ids), ("scope_id", scope_ids),
("store_scope_id", store_scope_ids)):
if values is None:
continue
if not 1 <= len(values) <= 1000:
raise AccountError("INVALID_ARGUMENT")
subjects.append((name, [int(value) for value in values]))
return tuple(subjects)
def _page(accounts, secret, *, gifts, account_ids, owner_client_id, client_id, page, size, start, end,
request_id, sub_account_ids=None, scope_ids=None, store_scope_ids=None):
subjects = _subject_filters(sub_account_ids, scope_ids, store_scope_ids)
with accounts.factory() as session: with accounts.factory() as session:
identity = _authenticate(session, secret) identity = _authenticate(session, secret)
_require_scope(identity, gifts, account_ids, owner_client_id, client_id) _require_scope(identity, gifts, account_ids, owner_client_id, client_id, subjects)
if page < 1 or not 1 <= size <= 200: if page < 1 or not 1 <= size <= 200:
raise AccountError("INVALID_ARGUMENT") raise AccountError("INVALID_ARGUMENT")
if account_ids is not None: if account_ids is not None:
...@@ -199,11 +241,11 @@ def _page(accounts, secret, *, gifts, account_ids, owner_client_id, client_id, p ...@@ -199,11 +241,11 @@ def _page(accounts, secret, *, gifts, account_ids, owner_client_id, client_id, p
audit_id = accounts.generator.next_id() if identity.category == "PLATFORM" else None audit_id = accounts.generator.next_id() if identity.category == "PLATFORM" else None
with accounts.factory.begin() as session: with accounts.factory.begin() as session:
principal = _authenticate(session, secret) principal = _authenticate(session, secret)
_require_scope(principal, gifts, account_ids, owner_client_id, client_id) _require_scope(principal, gifts, account_ids, owner_client_id, client_id, subjects)
scope = _account_scope(session, principal, account_ids, owner_client_id) scope = _account_scope(session, principal, account_ids, owner_client_id)
source_client = principal.client_id if principal.category == "CALL" else client_id source_client = principal.client_id if principal.category == "CALL" else client_id
projection = _gifts if gifts else _consumptions projection = _gifts if gifts else _consumptions
ledger = projection(scope, source_client, start, end, request_id).subquery() ledger = projection(scope, source_client, start, end, request_id, subjects).subquery()
total = session.scalar(select(func.count()).select_from(ledger)) total = session.scalar(select(func.count()).select_from(ledger))
offset = (page - 1) * size offset = (page - 1) * size
# Pages past the end are legitimately empty; never hand a huge offset # Pages past the end are legitimately empty; never hand a huge offset
...@@ -223,18 +265,116 @@ def _page(accounts, secret, *, gifts, account_ids, owner_client_id, client_id, p ...@@ -223,18 +265,116 @@ def _page(accounts, secret, *, gifts, account_ids, owner_client_id, client_id, p
evidence_ref={"accountIds": [str(value) for value in account_ids] if account_ids is not None else None, evidence_ref={"accountIds": [str(value) for value in account_ids] if account_ids is not None else None,
"ownerClientId": owner_client_id, "clientId": source_client, "ownerClientId": owner_client_id, "clientId": source_client,
"requestId": request_id, "page": page, "size": size, "requestId": request_id, "page": page, "size": size,
"start": _time(start), "end": _time(end)}, "start": _time(start), "end": _time(end),
**{"subAccountIds": _id_list(subjects, "sub_account_id"),
"scopeIds": _id_list(subjects, "scope_id"),
"storeScopeIds": _id_list(subjects, "store_scope_id")}},
)) ))
return data return data
def page_gifts(accounts, secret, *, account_ids=None, owner_client_id=None, client_id=None, def page_gifts(accounts, secret, *, account_ids=None, owner_client_id=None, client_id=None,
page=1, size=20, start=None, end=None, request_id=None): page=1, size=20, start=None, end=None, request_id=None,
sub_account_ids=None, scope_ids=None, store_scope_ids=None):
return _page(accounts, secret, gifts=True, account_ids=account_ids, owner_client_id=owner_client_id, return _page(accounts, secret, gifts=True, account_ids=account_ids, owner_client_id=owner_client_id,
client_id=client_id, page=page, size=size, start=start, end=end, request_id=request_id) client_id=client_id, page=page, size=size, start=start, end=end, request_id=request_id,
sub_account_ids=sub_account_ids, scope_ids=scope_ids, store_scope_ids=store_scope_ids)
def page_consumptions(accounts, secret, *, account_ids=None, owner_client_id=None, client_id=None, def page_consumptions(accounts, secret, *, account_ids=None, owner_client_id=None, client_id=None,
page=1, size=20, start=None, end=None, request_id=None): page=1, size=20, start=None, end=None, request_id=None,
sub_account_ids=None, scope_ids=None, store_scope_ids=None):
return _page(accounts, secret, gifts=False, account_ids=account_ids, owner_client_id=owner_client_id, return _page(accounts, secret, gifts=False, account_ids=account_ids, owner_client_id=owner_client_id,
client_id=client_id, page=page, size=size, start=start, end=end, request_id=request_id) client_id=client_id, page=page, size=size, start=start, end=end, request_id=request_id,
sub_account_ids=sub_account_ids, scope_ids=scope_ids, store_scope_ids=store_scope_ids)
def _month_projection(scope, month, subjects):
"""员工月用量行 + 主体/门店元数据 + 当前门店的生效额度(一次联查)。"""
store = aliased(Scope)
quota = aliased(ScopeQuota)
statement = (
select(
ScopeMonthUsage.scope_id, ScopeMonthUsage.month, ScopeMonthUsage.used_point_units,
Scope.scope_type, Scope.scope_key, Scope.status.label("scope_status"),
store.id.label("store_scope_id"), store.scope_key.label("store_scope_key"),
store.sub_account_id.label("sub_account_id"), store.status.label("store_status"),
quota.monthly_quota_point_units.label("limit_point_units"),
)
.select_from(ScopeMonthUsage)
.join(Scope, Scope.id == ScopeMonthUsage.scope_id)
.outerjoin(store, store.id == Scope.parent_scope_id)
.outerjoin(quota, and_(quota.scope_id == Scope.id, quota.store_scope_id == store.id))
.where(Scope.account_id.in_(scope), ScopeMonthUsage.month == month)
)
for name, values in subjects:
column = {"sub_account_id": store.sub_account_id, "scope_id": Scope.id,
"store_scope_id": store.id}[name]
statement = statement.where(column.in_(values))
return statement
def _month_view(row, month, current):
# 历史月的生效额度只能按设计 §3.3 用审计重放,本接口不猜:只有当月才带额度。
limited = month == current
limit = row.limit_point_units if limited else None
return {
"scopeId": units_json(row.scope_id), "scopeType": row.scope_type, "scopeKey": row.scope_key,
"storeScopeId": units_json(row.store_scope_id), "storeScopeKey": row.store_scope_key,
"subAccountId": units_json(row.sub_account_id), "month": row.month,
"usedPointUnits": units_json(row.used_point_units), "usedPoints": format_points(row.used_point_units),
"limitPointUnits": units_json(limit), "limitPoints": format_points(limit) if limited else None,
"unlimited": (limit is None) if limited else None,
"scopeStatus": row.scope_status, "storeStatus": row.store_status,
}
def page_month_usage(accounts, secret, *, account_ids=None, owner_client_id=None, month=None,
sub_account_ids=None, scope_ids=None, store_scope_ids=None, page=1, size=20):
"""月账单:`scope_month_usage` 直读(设计 §8)。管理查询,CALL 主体不可用。"""
subjects = _subject_filters(sub_account_ids, scope_ids, store_scope_ids)
month = month or current_month()
if not isinstance(month, str) or not _MONTH_PATTERN.match(month):
raise AccountError("INVALID_ARGUMENT")
with accounts.factory() as session:
identity = _authenticate(session, secret)
if identity.category == "CALL":
# 主体自己的用量走 /api/v1 的消费账本;月账单是管理侧报表。
raise AccountError("PERMISSION_DENIED")
_require_scope(identity, False, account_ids, owner_client_id, None, ())
if page < 1 or not 1 <= size <= 200:
raise AccountError("INVALID_ARGUMENT")
if account_ids is not None:
if not 1 <= len(account_ids) <= 1000:
raise AccountError("INVALID_ARGUMENT")
account_ids = [int(value) for value in account_ids]
audit_id = accounts.generator.next_id() if identity.category == "PLATFORM" else None
with accounts.factory.begin() as session:
principal = _authenticate(session, secret)
if principal.category == "CALL":
raise AccountError("PERMISSION_DENIED")
_require_scope(principal, False, account_ids, owner_client_id, None, ())
scope = _account_scope(session, principal, account_ids, owner_client_id)
ledger = _month_projection(scope, month, subjects).subquery()
total = session.scalar(select(func.count()).select_from(ledger))
offset = (page - 1) * size
rows = []
if offset < total:
rows = session.execute(select(ledger)
.order_by(ledger.c.used_point_units.desc(), ledger.c.scope_id)
.offset(offset).limit(size)).all()
data = {"total": total, "page": page, "size": size, "month": month,
"list": [_month_view(row, month, current_month()) for row in rows]}
if audit_id is not None:
session.add(OperationAudit(
id=audit_id, actor_credential_id=principal.credential_id, client_id=principal.client_id,
action="QUERY_MONTH_USAGE", target_type="CONSUMPTION", target_id=None,
request_id=str(audit_id), reason="Read monthly scope usage",
evidence_ref={"accountIds": [str(value) for value in account_ids] if account_ids is not None else None,
"ownerClientId": owner_client_id, "month": month, "page": page, "size": size,
"subAccountIds": _id_list(subjects, "sub_account_id"),
"scopeIds": _id_list(subjects, "scope_id"),
"storeScopeIds": _id_list(subjects, "store_scope_id")},
))
return data
...@@ -19,12 +19,15 @@ from .account_calls import CALL_TIMEOUT_SECONDS, CallService ...@@ -19,12 +19,15 @@ from .account_calls import CALL_TIMEOUT_SECONDS, CallService
from .account_config import load_account_settings from .account_config import load_account_settings
from .account_model_gateway import ModelGateway from .account_model_gateway import ModelGateway
from .account_gifts import GiftService from .account_gifts import GiftService
from .account_ledger import page_consumptions, page_gifts from .account_ledger import page_consumptions, page_gifts, page_month_usage
from .account_models import Base, IdSegment from .account_models import Base, IdSegment
from .account_schemas import ( from .account_schemas import (
AccountQueryRequest, ActivateCredentialRequest, ClientId, CreateAccountRequest, GiftRequest, Identifier, AccountQueryRequest, ActivateCredentialRequest, BindScopeRequest, ClientId, CreateAccountRequest,
ChatRequest, IssueCredentialRequest, LedgerPageRequest, ReconcileCallRequest, ReconcileGiftRequest, CreateSubAccountRequest, GiftRequest, Identifier, ChatRequest, IssueCredentialRequest,
RevokeCredentialRequest, SetAccountStatusRequest, SetClientRequest, LedgerPageRequest, MoveEmployeeRequest, MoveStoreRequest, ReconcileCallRequest,
ScopeMonthPageRequest,
ReconcileGiftRequest, RevokeCredentialRequest, SetAccountStatusRequest, SetClientRequest,
SetScopeQuotaRequest, SetScopeStatusRequest, SetSubAccountStatusRequest, TransferRequest,
) )
from .account_openai import ( from .account_openai import (
OpenAIChatRequest, OpenAIStreamResponse, completion_response, execution_error, models_response, openai_error, OpenAIChatRequest, OpenAIStreamResponse, completion_response, execution_error, models_response, openai_error,
...@@ -32,7 +35,7 @@ from .account_openai import ( ...@@ -32,7 +35,7 @@ from .account_openai import (
from .account_security import AccountError, ERRORS, require_management, validate_request_id from .account_security import AccountError, ERRORS, require_management, validate_request_id
from .account_service import AccountService from .account_service import AccountService
from .billing import EXPECTED_QUOTA_PER_UNIT from .billing import EXPECTED_QUOTA_PER_UNIT
from .db import create_mysql_engine from .db import create_generator_engine, create_mysql_engine
from .errors import AppError from .errors import AppError
from .id_generator import GENERATOR_KEY, ID_HIGH_EXCLUSIVE, ID_LOW, SegmentIDGenerator from .id_generator import GENERATOR_KEY, ID_HIGH_EXCLUSIVE, ID_LOW, SegmentIDGenerator
...@@ -118,7 +121,8 @@ class AccountBoundary: ...@@ -118,7 +121,8 @@ class AccountBoundary:
elif (path.startswith("/api/") or path.startswith("/v1/")) and principal.category != "CALL": elif (path.startswith("/api/") or path.startswith("/v1/")) and principal.category != "CALL":
raise AccountError("PERMISSION_DENIED") raise AccountError("PERMISSION_DENIED")
read_posts = {"/internal/v1/accounts/query", "/api/v1/account/availability", read_posts = {"/internal/v1/accounts/query", "/api/v1/account/availability",
"/internal/v1/gifts/page", "/internal/v1/consumptions/page", "/api/v1/consumptions/page"} "/internal/v1/gifts/page", "/internal/v1/consumptions/page",
"/internal/v1/consumptions/months", "/api/v1/consumptions/page"}
# OpenAI 协议没有幂等请求头:/v1 调用缺省时由服务端生成请求号;客户端自带 X-Request-Id 仍可幂等重放。 # OpenAI 协议没有幂等请求头:/v1 调用缺省时由服务端生成请求号;客户端自带 X-Request-Id 仍可幂等重放。
if (scope["method"] == "POST" and path not in read_posts and path != "/v1/chat/completions" if (scope["method"] == "POST" and path not in read_posts and path != "/v1/chat/completions"
and request_id is None): and request_id is None):
...@@ -210,7 +214,7 @@ def create_account_app(*, settings=None, service=None): ...@@ -210,7 +214,7 @@ def create_account_app(*, settings=None, service=None):
app.state.service = service app.state.service = service
yield yield
return return
engine = gateway = None engine = generator_engine = gateway = None
try: try:
if settings is None: if settings is None:
raise RuntimeError("必须通过显式配置文件启动") raise RuntimeError("必须通过显式配置文件启动")
...@@ -219,19 +223,27 @@ def create_account_app(*, settings=None, service=None): ...@@ -219,19 +223,27 @@ def create_account_app(*, settings=None, service=None):
max_overflow=settings.db_max_overflow, bounded_operations=True) max_overflow=settings.db_max_overflow, bounded_operations=True)
await run_in_threadpool(_database_ready, engine) await run_in_threadpool(_database_ready, engine)
factory = sessionmaker(engine, expire_on_commit=False) factory = sessionmaker(engine, expire_on_commit=False)
# 发号器独立小池:号段分配不得占用业务连接池(见 create_generator_engine)。
generator_engine = create_generator_engine(
settings.database_url, pool_size=settings.db_generator_pool_size,
max_overflow=settings.db_generator_max_overflow)
generator_factory = sessionmaker(generator_engine, expire_on_commit=False)
gateway = ModelGateway(settings) gateway = ModelGateway(settings)
app.state.service = AccountService(factory, SegmentIDGenerator(factory, segment_model=IdSegment), gateway, settings) app.state.service = AccountService(factory, SegmentIDGenerator(generator_factory, segment_model=IdSegment), gateway, settings)
except Exception: except Exception:
if gateway is not None: if gateway is not None:
await gateway.aclose() await gateway.aclose()
if engine is not None: if engine is not None:
engine.dispose() engine.dispose()
if generator_engine is not None:
generator_engine.dispose()
raise RuntimeError("独立算力服务初始化失败,请核查配置与数据库") from None raise RuntimeError("独立算力服务初始化失败,请核查配置与数据库") from None
try: try:
yield yield
finally: finally:
await gateway.aclose() await gateway.aclose()
engine.dispose() engine.dispose()
generator_engine.dispose()
app = FastAPI(title="mei1-computing-service", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(title="mei1-computing-service", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
if service is not None: if service is not None:
...@@ -309,8 +321,11 @@ def create_account_app(*, settings=None, service=None): ...@@ -309,8 +321,11 @@ def create_account_app(*, settings=None, service=None):
@app.post("/internal/v1/accounts/{account_id}/credentials") @app.post("/internal/v1/accounts/{account_id}/credentials")
def issue(request: Request, account_id: Identifier, body: IssueCredentialRequest): def issue(request: Request, account_id: Identifier, body: IssueCredentialRequest):
return reply(request, request.app.state.service.issue_credential(request.state.secret, write_id(request, body), return reply(request, request.app.state.service.issue_credential(
int(account_id), body.client_id)) request.state.secret, write_id(request, body), int(account_id), body.client_id,
int(body.sub_account_id) if body.sub_account_id else None,
int(body.scope_id) if body.scope_id else None,
))
@app.post("/internal/v1/credentials/{credential_id}/activate") @app.post("/internal/v1/credentials/{credential_id}/activate")
def activate(request: Request, credential_id: Identifier, body: ActivateCredentialRequest): def activate(request: Request, credential_id: Identifier, body: ActivateCredentialRequest):
...@@ -326,8 +341,17 @@ def create_account_app(*, settings=None, service=None): ...@@ -326,8 +341,17 @@ def create_account_app(*, settings=None, service=None):
)) ))
@app.get("/internal/v1/accounts/{account_id}/credentials") @app.get("/internal/v1/accounts/{account_id}/credentials")
def credentials(request: Request, account_id: Identifier, client_id: Optional[str] = Query(default=None, alias="clientId")): def credentials(request: Request, account_id: Identifier,
return reply(request, request.app.state.service.list_credentials(request.state.secret, int(account_id), client_id)) client_id: Optional[str] = Query(default=None, alias="clientId"),
status: Optional[str] = Query(default=None),
sub_account_id: Optional[Identifier] = Query(default=None, alias="subAccountId"),
scope_id: Optional[Identifier] = Query(default=None, alias="scopeId"),
page: int = Query(default=1, ge=1),
size: int = Query(default=20, ge=1, le=200)):
return reply(request, request.app.state.service.list_credentials(
request.state.secret, int(account_id), client_id, status=status,
sub_account_id=None if sub_account_id is None else int(sub_account_id),
scope_id=None if scope_id is None else int(scope_id), page=page, size=size))
@app.post("/internal/v1/accounts/{account_id}/clients") @app.post("/internal/v1/accounts/{account_id}/clients")
def clients(request: Request, account_id: Identifier, body: SetClientRequest): def clients(request: Request, account_id: Identifier, body: SetClientRequest):
...@@ -339,6 +363,74 @@ def create_account_app(*, settings=None, service=None): ...@@ -339,6 +363,74 @@ def create_account_app(*, settings=None, service=None):
return reply(request, request.app.state.service.set_status(request.state.secret, write_id(request, body), return reply(request, request.app.state.service.set_status(request.state.secret, write_id(request, body),
int(account_id), body.status, body.reason)) int(account_id), body.status, body.reason))
@app.post("/internal/v1/accounts/{account_id}/sub-accounts")
def create_sub_account(request: Request, account_id: Identifier, body: CreateSubAccountRequest):
return reply(request, request.app.state.service.create_sub_account(
request.state.secret, write_id(request, body), int(account_id), body.name, body.remark))
@app.get("/internal/v1/accounts/{account_id}/sub-accounts")
def sub_accounts(request: Request, account_id: Identifier, page: int = Query(default=1, ge=1),
size: int = Query(default=20, ge=1, le=200)):
return reply(request, request.app.state.service.list_sub_accounts(
request.state.secret, int(account_id), page, size))
@app.post("/internal/v1/accounts/{account_id}/sub-accounts/{sub_account_id}/status")
def sub_account_status(request: Request, account_id: Identifier, sub_account_id: Identifier,
body: SetSubAccountStatusRequest):
return reply(request, request.app.state.service.set_sub_account_status(
request.state.secret, write_id(request, body), int(account_id), int(sub_account_id),
body.status, body.reason))
@app.post("/internal/v1/accounts/{account_id}/sub-accounts/{sub_account_id}/transfers")
def transfer(request: Request, account_id: Identifier, sub_account_id: Identifier,
body: TransferRequest):
return reply(request, request.app.state.service.transfer(
request.state.secret, write_id(request, body), int(account_id), int(sub_account_id),
body.points, body.direction, body.operator_note))
@app.post("/internal/v1/accounts/{account_id}/scopes")
def bind_scope(request: Request, account_id: Identifier, body: BindScopeRequest):
return reply(request, request.app.state.service.bind_scope(
request.state.secret, write_id(request, body), int(account_id), body.scope_type,
body.scope_key, body.sub_account_id, body.parent_scope_key))
@app.get("/internal/v1/accounts/{account_id}/scopes")
def scopes(request: Request, account_id: Identifier, page: int = Query(default=1, ge=1),
size: int = Query(default=20, ge=1, le=200),
scope_type: Optional[str] = Query(default=None, alias="scopeType"),
sub_account_id: Optional[Identifier] = Query(default=None, alias="subAccountId")):
return reply(request, request.app.state.service.list_scopes(
request.state.secret, int(account_id), scope_type,
None if sub_account_id is None else int(sub_account_id), page, size))
@app.post("/internal/v1/accounts/{account_id}/scopes/{scope_id}/status")
def scope_status(request: Request, account_id: Identifier, scope_id: Identifier,
body: SetScopeStatusRequest):
return reply(request, request.app.state.service.set_scope_status(
request.state.secret, write_id(request, body), int(account_id), int(scope_id),
body.status, body.reason))
@app.post("/internal/v1/accounts/{account_id}/scopes/{scope_id}/sub-account")
def store_assignment(request: Request, account_id: Identifier, scope_id: Identifier,
body: MoveStoreRequest):
return reply(request, request.app.state.service.move_store(
request.state.secret, write_id(request, body), int(account_id), int(scope_id),
body.sub_account_id))
@app.post("/internal/v1/accounts/{account_id}/scopes/{scope_id}/assignment")
def employee_assignment(request: Request, account_id: Identifier, scope_id: Identifier,
body: MoveEmployeeRequest):
return reply(request, request.app.state.service.move_employee(
request.state.secret, write_id(request, body), int(account_id), int(scope_id),
body.parent_scope_id))
@app.post("/internal/v1/accounts/{account_id}/scopes/{scope_id}/quota")
def scope_quota(request: Request, account_id: Identifier, scope_id: Identifier,
body: SetScopeQuotaRequest):
return reply(request, request.app.state.service.set_scope_quota(
request.state.secret, write_id(request, body), int(account_id), int(scope_id),
body.store_scope_id, body.monthly_quota_points))
@app.post("/internal/v1/gifts") @app.post("/internal/v1/gifts")
async def gift(request: Request, body: GiftRequest): async def gift(request: Request, body: GiftRequest):
data = await GiftService(request.app.state.service).gift(request.state.secret, write_id(request, body), body) data = await GiftService(request.app.state.service).gift(request.state.secret, write_id(request, body), body)
...@@ -380,6 +472,11 @@ def create_account_app(*, settings=None, service=None): ...@@ -380,6 +472,11 @@ def create_account_app(*, settings=None, service=None):
) )
return reply(request, data) return reply(request, data)
@app.post("/internal/v1/consumptions/months")
def usage_months(request: Request, body: ScopeMonthPageRequest):
return reply(request, page_month_usage(request.app.state.service, request.state.secret,
**body.model_dump()))
@app.post("/api/v1/consumptions/page") @app.post("/api/v1/consumptions/page")
@app.post("/internal/v1/consumptions/page") @app.post("/internal/v1/consumptions/page")
def consumptions_page(request: Request, body: LedgerPageRequest): def consumptions_page(request: Request, body: LedgerPageRequest):
......
...@@ -2,6 +2,7 @@ from datetime import datetime, timezone ...@@ -2,6 +2,7 @@ from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy import ( from sqlalchemy import (
CHAR,
JSON, JSON,
BigInteger, BigInteger,
Boolean, Boolean,
...@@ -120,6 +121,15 @@ _SETTLEMENT_STATUSES = ( ...@@ -120,6 +121,15 @@ _SETTLEMENT_STATUSES = (
"SETTLE_FAILED", "SETTLE_FAILED",
"UNKNOWN", "UNKNOWN",
) )
_POINT_RECORD_TYPES = ("GIFT", "CONSUME", "TRANSFER_OUT", "TRANSFER_IN")
_MANAGEMENT_OPERATIONS = (
"PROVISION_ACCOUNT", "ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL",
"REVOKE_CREDENTIAL", "GRANT_CLIENT", "REVOKE_CLIENT",
"SET_ACCOUNT_STATUS", "RECONCILE",
"CREATE_SUB_ACCOUNT", "SET_SUB_ACCOUNT_STATUS", "TRANSFER",
"BIND_SCOPE", "SET_SCOPE_STATUS", "MOVE_STORE", "MOVE_EMPLOYEE",
"SET_SCOPE_QUOTA",
)
class Base(DeclarativeBase): class Base(DeclarativeBase):
...@@ -218,8 +228,10 @@ class Credential(_Timestamps, Base): ...@@ -218,8 +228,10 @@ class Credential(_Timestamps, Base):
__table_args__ = ( __table_args__ = (
_account_client_fk("fk_computing_credential_account_client"), _account_client_fk("fk_computing_credential_account_client"),
CheckConstraint( CheckConstraint(
"(category = 'CALL' AND account_id IS NOT NULL AND issued_by IS NOT NULL) " "(category = 'CALL' AND account_id IS NOT NULL AND issued_by IS NOT NULL "
"OR (category IN ('INTEGRATION', 'PLATFORM') AND account_id IS NULL)", "AND NOT (sub_account_id IS NOT NULL AND scope_id IS NOT NULL)) "
"OR (category IN ('INTEGRATION', 'PLATFORM') AND account_id IS NULL "
"AND sub_account_id IS NULL AND scope_id IS NULL)",
name="ck_computing_credential_scope", name="ck_computing_credential_scope",
), ),
Index("idx_computing_credential_scope_status", "account_id", "client_id", "status"), Index("idx_computing_credential_scope_status", "account_id", "client_id", "status"),
...@@ -234,6 +246,12 @@ class Credential(_Timestamps, Base): ...@@ -234,6 +246,12 @@ class Credential(_Timestamps, Base):
_identifier(50), ForeignKey("t_computing_client.client_id") _identifier(50), ForeignKey("t_computing_client.client_id")
) )
account_id: Mapped[Optional[int]] = mapped_column(_ID) account_id: Mapped[Optional[int]] = mapped_column(_ID)
sub_account_id: Mapped[Optional[int]] = mapped_column(
_ID, ForeignKey("t_computing_sub_account.id")
)
scope_id: Mapped[Optional[int]] = mapped_column(
_ID, ForeignKey("t_computing_scope.id")
)
secret_digest: Mapped[str] = mapped_column(_identifier(64), unique=True) secret_digest: Mapped[str] = mapped_column(_identifier(64), unique=True)
secret_mask: Mapped[str] = mapped_column(_identifier(20)) secret_mask: Mapped[str] = mapped_column(_identifier(20))
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
...@@ -271,12 +289,7 @@ class ManagementRequest(_Timestamps, Base): ...@@ -271,12 +289,7 @@ class ManagementRequest(_Timestamps, Base):
_ID, ForeignKey("t_computing_credential.id") _ID, ForeignKey("t_computing_credential.id")
) )
operation_type: Mapped[str] = mapped_column( operation_type: Mapped[str] = mapped_column(
_enum( _enum("computing_management_request_operation", *_MANAGEMENT_OPERATIONS)
"computing_management_request_operation",
"PROVISION_ACCOUNT", "ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL",
"REVOKE_CREDENTIAL", "GRANT_CLIENT", "REVOKE_CLIENT",
"SET_ACCOUNT_STATUS", "RECONCILE",
)
) )
request_id: Mapped[str] = mapped_column(_identifier(100)) request_id: Mapped[str] = mapped_column(_identifier(100))
fingerprint: Mapped[str] = mapped_column(_identifier(64)) fingerprint: Mapped[str] = mapped_column(_identifier(64))
...@@ -407,6 +420,10 @@ class Call(_Timestamps, Base): ...@@ -407,6 +420,10 @@ class Call(_Timestamps, Base):
name="ck_computing_call_lease", name="ck_computing_call_lease",
), ),
Index("idx_computing_call_account_created", "account_id", "create_time", "id"), Index("idx_computing_call_account_created", "account_id", "create_time", "id"),
# 分账报表(设计 §8 段 3e 实测):门店账/员工账按维度分页要能直接定位,否则
# LIMIT 20 也要顺着 account+create_time 逆序读到匹配行为止(实测差 2-3 个数量级)。
Index("idx_computing_call_store_scope", "account_id", "store_scope_id", "create_time", "id"),
Index("idx_computing_call_scope_subject", "account_id", "scope_id", "create_time", "id"),
Index( Index(
"idx_computing_call_retry", "billing_status", "next_retry_time", "lease_until" "idx_computing_call_retry", "billing_status", "next_retry_time", "lease_until"
), ),
...@@ -470,6 +487,14 @@ class Call(_Timestamps, Base): ...@@ -470,6 +487,14 @@ class Call(_Timestamps, Base):
_ID, ForeignKey("t_computing_credential.id") _ID, ForeignKey("t_computing_credential.id")
) )
complete_time: Mapped[Optional[datetime]] = mapped_column(UTCDateTime(3)) complete_time: Mapped[Optional[datetime]] = mapped_column(UTCDateTime(3))
sub_account_id: Mapped[Optional[int]] = mapped_column(_ID)
scope_id: Mapped[Optional[int]] = mapped_column(_ID)
store_scope_id: Mapped[Optional[int]] = mapped_column(_ID)
quota_month: Mapped[Optional[str]] = mapped_column(
CHAR(7).with_variant(
mysql.CHAR(7, charset="ascii", collation="ascii_bin"), "mysql"
)
)
class Consumption(_Timestamps, Base): class Consumption(_Timestamps, Base):
...@@ -501,6 +526,9 @@ class Consumption(_Timestamps, Base): ...@@ -501,6 +526,9 @@ class Consumption(_Timestamps, Base):
"legacy_record = 1 OR call_id IS NOT NULL", name="ck_computing_consumption_call" "legacy_record = 1 OR call_id IS NOT NULL", name="ck_computing_consumption_call"
), ),
Index("idx_computing_consumption_account_created", "account_id", "create_time", "id"), Index("idx_computing_consumption_account_created", "account_id", "create_time", "id"),
# 与 call 同口径:分账报表的 union 另一支(legacy 行)也要能走维度索引。
Index("idx_computing_consumption_store_scope", "account_id", "store_scope_id", "create_time", "id"),
Index("idx_computing_consumption_scope_subject", "account_id", "scope_id", "create_time", "id"),
_TABLE_OPTIONS, _TABLE_OPTIONS,
) )
...@@ -531,6 +559,9 @@ class Consumption(_Timestamps, Base): ...@@ -531,6 +559,9 @@ class Consumption(_Timestamps, Base):
legacy_record: Mapped[bool] = mapped_column( legacy_record: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("0") Boolean, default=False, server_default=text("0")
) )
sub_account_id: Mapped[Optional[int]] = mapped_column(_ID)
scope_id: Mapped[Optional[int]] = mapped_column(_ID)
store_scope_id: Mapped[Optional[int]] = mapped_column(_ID)
class PointRecord(_Timestamps, Base): class PointRecord(_Timestamps, Base):
...@@ -561,20 +592,30 @@ class PointRecord(_Timestamps, Base): ...@@ -561,20 +592,30 @@ class PointRecord(_Timestamps, Base):
), ),
CheckConstraint( CheckConstraint(
"(type = 'GIFT' AND point_units > 0 AND call_id IS NULL " "(type = 'GIFT' AND point_units > 0 AND call_id IS NULL "
"AND consumption_record_id IS NULL AND (legacy_record = 1 OR gift_id IS NOT NULL)) " "AND consumption_record_id IS NULL AND sub_account_id IS NULL "
"AND (legacy_record = 1 OR gift_id IS NOT NULL)) "
"OR (type = 'CONSUME' AND point_units < 0 AND gift_id IS NULL " "OR (type = 'CONSUME' AND point_units < 0 AND gift_id IS NULL "
"AND consumption_record_id IS NOT NULL " "AND consumption_record_id IS NOT NULL "
"AND (legacy_record = 1 OR call_id IS NOT NULL))", "AND (legacy_record = 1 OR call_id IS NOT NULL)) "
"OR (type = 'TRANSFER_OUT' AND point_units < 0 AND gift_id IS NULL "
"AND call_id IS NULL AND consumption_record_id IS NULL) "
"OR (type = 'TRANSFER_IN' AND point_units > 0 AND gift_id IS NULL "
"AND call_id IS NULL AND consumption_record_id IS NULL)",
name="ck_computing_point_record_source", name="ck_computing_point_record_source",
), ),
Index("idx_computing_point_record_account_created", "account_id", "create_time", "id"), Index("idx_computing_point_record_account_created", "account_id", "create_time", "id"),
Index(
"idx_computing_point_record_bucket", "account_id", "sub_account_id", "type"
),
_TABLE_OPTIONS, _TABLE_OPTIONS,
) )
id: Mapped[int] = mapped_column(_ID, primary_key=True, autoincrement=False) id: Mapped[int] = mapped_column(_ID, primary_key=True, autoincrement=False)
account_id: Mapped[int] = mapped_column(_ID) account_id: Mapped[int] = mapped_column(_ID)
client_id: Mapped[str] = mapped_column(_identifier(50)) client_id: Mapped[str] = mapped_column(_identifier(50))
type: Mapped[str] = mapped_column(_enum("computing_point_record_type", "GIFT", "CONSUME")) type: Mapped[str] = mapped_column(
_enum("computing_point_record_type", *_POINT_RECORD_TYPES)
)
point_units: Mapped[int] = mapped_column(BigInteger) point_units: Mapped[int] = mapped_column(BigInteger)
balance_before_units: Mapped[int] = mapped_column(BigInteger) balance_before_units: Mapped[int] = mapped_column(BigInteger)
balance_after_units: Mapped[int] = mapped_column(BigInteger) balance_after_units: Mapped[int] = mapped_column(BigInteger)
...@@ -582,6 +623,9 @@ class PointRecord(_Timestamps, Base): ...@@ -582,6 +623,9 @@ class PointRecord(_Timestamps, Base):
gift_id: Mapped[Optional[int]] = mapped_column(_ID, unique=True) gift_id: Mapped[Optional[int]] = mapped_column(_ID, unique=True)
call_id: Mapped[Optional[int]] = mapped_column(_ID, unique=True) call_id: Mapped[Optional[int]] = mapped_column(_ID, unique=True)
consumption_record_id: Mapped[Optional[int]] = mapped_column(_ID, unique=True) consumption_record_id: Mapped[Optional[int]] = mapped_column(_ID, unique=True)
sub_account_id: Mapped[Optional[int]] = mapped_column(
_ID, ForeignKey("t_computing_sub_account.id")
)
legacy_record: Mapped[bool] = mapped_column( legacy_record: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("0") Boolean, default=False, server_default=text("0")
) )
...@@ -589,6 +633,127 @@ class PointRecord(_Timestamps, Base): ...@@ -589,6 +633,127 @@ class PointRecord(_Timestamps, Base):
remark: Mapped[Optional[str]] = mapped_column(String(500)) remark: Mapped[Optional[str]] = mapped_column(String(500))
class SubAccount(_Timestamps, Base):
__tablename__ = "t_computing_sub_account"
__table_args__ = (
UniqueConstraint("account_id", "name", name="uq_computing_sub_account_name"),
Index("idx_computing_sub_account_created", "account_id", "create_time", "id"),
_TABLE_OPTIONS,
)
id: Mapped[int] = mapped_column(_ID, primary_key=True, autoincrement=False)
account_id: Mapped[int] = mapped_column(
_ID, ForeignKey("t_computing_account.id")
)
name: Mapped[str] = mapped_column(String(100))
remark: Mapped[Optional[str]] = mapped_column(String(500))
status: Mapped[str] = mapped_column(
_enum("computing_sub_account_status", "ACTIVE", "DISABLED"),
default="ACTIVE",
server_default="ACTIVE",
)
balance_point_units: Mapped[int] = mapped_column(
BigInteger, default=0, server_default=text("0")
)
version: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
class Scope(_Timestamps, Base):
__tablename__ = "t_computing_scope"
__table_args__ = (
UniqueConstraint("scope_type", "scope_key", name="uq_computing_scope_identity"),
CheckConstraint(
"(scope_type = 'STORE' AND parent_scope_id IS NULL) "
"OR (scope_type = 'EMPLOYEE' AND parent_scope_id IS NOT NULL "
"AND sub_account_id IS NULL)",
name="ck_computing_scope_shape",
),
ForeignKeyConstraint(
["account_id"], ["t_computing_account.id"],
name="fk_computing_scope_account",
),
ForeignKeyConstraint(
["sub_account_id"], ["t_computing_sub_account.id"],
name="fk_computing_scope_sub_account",
),
ForeignKeyConstraint(
["parent_scope_id"], ["t_computing_scope.id"],
name="fk_computing_scope_parent",
),
Index("idx_computing_scope_sub_account", "sub_account_id", "scope_type", "status"),
Index("idx_computing_scope_parent", "parent_scope_id"),
_TABLE_OPTIONS,
)
id: Mapped[int] = mapped_column(_ID, primary_key=True, autoincrement=False)
account_id: Mapped[int] = mapped_column(_ID)
sub_account_id: Mapped[Optional[int]] = mapped_column(_ID)
scope_type: Mapped[str] = mapped_column(
_enum("computing_scope_type", "STORE", "EMPLOYEE")
)
scope_key: Mapped[str] = mapped_column(_identifier(100))
parent_scope_id: Mapped[Optional[int]] = mapped_column(_ID)
status: Mapped[str] = mapped_column(
_enum("computing_scope_status", "ACTIVE", "DISABLED"),
default="ACTIVE",
server_default="ACTIVE",
)
version: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
class ScopeQuota(_Timestamps, Base):
__tablename__ = "t_computing_scope_quota"
__table_args__ = (
UniqueConstraint("scope_id", "store_scope_id", name="uq_computing_scope_quota_pair"),
CheckConstraint(
"monthly_quota_point_units IS NULL OR monthly_quota_point_units >= 0",
name="ck_computing_scope_quota_nonnegative",
),
ForeignKeyConstraint(
["scope_id"], ["t_computing_scope.id"], name="fk_computing_scope_quota_scope"
),
ForeignKeyConstraint(
["store_scope_id"], ["t_computing_scope.id"],
name="fk_computing_scope_quota_store",
),
_TABLE_OPTIONS,
)
id: Mapped[int] = mapped_column(_ID, primary_key=True, autoincrement=False)
scope_id: Mapped[int] = mapped_column(_ID)
store_scope_id: Mapped[int] = mapped_column(_ID)
monthly_quota_point_units: Mapped[Optional[int]] = mapped_column(BigInteger)
version: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
class ScopeMonthUsage(_Timestamps, Base):
__tablename__ = "t_computing_scope_month_usage"
__table_args__ = (
ForeignKeyConstraint(
["scope_id"], ["t_computing_scope.id"],
name="fk_computing_scope_month_usage_scope",
),
CheckConstraint(
"used_point_units >= 0", name="ck_computing_scope_month_usage_nonnegative"
),
_TABLE_OPTIONS,
)
scope_id: Mapped[int] = mapped_column(
_ID, primary_key=True, autoincrement=False
)
month: Mapped[str] = mapped_column(
CHAR(7).with_variant(
mysql.CHAR(7, charset="ascii", collation="ascii_bin"), "mysql"
),
primary_key=True,
autoincrement=False,
)
used_point_units: Mapped[int] = mapped_column(
BigInteger, default=0, server_default=text("0")
)
class IdSegment(_Timestamps, Base): class IdSegment(_Timestamps, Base):
__tablename__ = "t_computing_id_segment" __tablename__ = "t_computing_id_segment"
__table_args__ = ( __table_args__ = (
......
...@@ -84,6 +84,8 @@ def openai_error(error: AccountError) -> JSONResponse: ...@@ -84,6 +84,8 @@ def openai_error(error: AccountError) -> JSONResponse:
error_type = "api_error" error_type = "api_error"
elif error.code == "RATE_LIMITED": elif error.code == "RATE_LIMITED":
error_type = "rate_limit_error" error_type = "rate_limit_error"
elif error.code == "EMPLOYEE_QUOTA_EXCEEDED":
status, error_type = 429, "insufficient_quota"
elif error.code == "ACCOUNT_BLOCKED" and reason == "INSUFFICIENT_BALANCE": elif error.code == "ACCOUNT_BLOCKED" and reason == "INSUFFICIENT_BALANCE":
status, error_type = 429, "insufficient_quota" status, error_type = 429, "insufficient_quota"
else: else:
......
from sqlalchemy import func, select from sqlalchemy import and_, func, select
from sqlalchemy.orm import aliased
from .account_models import Account, AccountClient, Call, Consumption, Gift, ManagementRequest from .account_models import (
Account,
AccountClient,
Call,
Consumption,
Gift,
ManagementRequest,
Scope,
ScopeMonthUsage,
ScopeQuota,
SubAccount,
)
from .account_security import utc_now
def get_account(session, account_id, client_id): def get_account(session, account_id, client_id):
...@@ -24,6 +37,185 @@ def lock_account(session, account_id): ...@@ -24,6 +37,185 @@ def lock_account(session, account_id):
) )
def find_sub_account(session, account_id, sub_account_id):
return session.scalar(
select(SubAccount).where(
SubAccount.id == sub_account_id,
SubAccount.account_id == account_id,
)
)
def lock_sub_account(session, sub_account_id):
return session.scalar(
select(SubAccount)
.where(SubAccount.id == sub_account_id)
.with_for_update()
.execution_options(populate_existing=True)
)
def page_sub_accounts(session, account_id, *, page=1, size=20):
if page < 1 or not 1 <= size <= 200:
raise ValueError("分页范围无效")
conditions = [SubAccount.account_id == account_id]
total = session.scalar(select(func.count()).select_from(SubAccount).where(*conditions))
rows = []
if (page - 1) * size < total:
rows = session.scalars(
select(SubAccount)
.where(*conditions)
.order_by(SubAccount.create_time.desc(), SubAccount.id.desc())
.offset((page - 1) * size)
.limit(size)
).all()
return total, list(rows)
def find_scope(session, account_id, scope_id):
return session.scalar(
select(Scope).where(Scope.id == scope_id, Scope.account_id == account_id)
)
def find_scope_by_key(session, scope_type, scope_key):
"""Scope keys are globally unique: one store key can only ever exist once,
which is what makes "a store belongs to exactly one account" structural."""
return session.scalar(
select(Scope).where(Scope.scope_type == scope_type, Scope.scope_key == scope_key)
)
def lock_scope(session, scope_id):
return session.scalar(
select(Scope)
.where(Scope.id == scope_id)
.with_for_update()
.execution_options(populate_existing=True)
)
def page_scopes(session, account_id, *, scope_type=None, sub_account_id=None, page=1, size=20):
if page < 1 or not 1 <= size <= 200:
raise ValueError("分页范围无效")
conditions = [Scope.account_id == account_id]
if scope_type is not None:
conditions.append(Scope.scope_type == scope_type)
if sub_account_id is not None:
conditions.append(Scope.sub_account_id == sub_account_id)
total = session.scalar(select(func.count()).select_from(Scope).where(*conditions))
rows = []
if (page - 1) * size < total:
rows = session.scalars(
select(Scope)
.where(*conditions)
.order_by(Scope.create_time.desc(), Scope.id.desc())
.offset((page - 1) * size)
.limit(size)
).all()
return total, list(rows)
def find_scope_quota(session, scope_id, store_scope_id):
return session.scalar(
select(ScopeQuota).where(
ScopeQuota.scope_id == scope_id, ScopeQuota.store_scope_id == store_scope_id
)
)
def find_month_usage(session, scope_id, month):
"""当月已用(缺失 = 0,与 UPSERT 的起点一致)。"""
value = session.scalar(
select(ScopeMonthUsage.used_point_units).where(
ScopeMonthUsage.scope_id == scope_id, ScopeMonthUsage.month == month
)
)
return 0 if value is None else value
def resolve_call_subject(session, scope_id, month):
"""Resolve a store/employee call subject in a single query: the scope row,
its parent store, the charging bucket and the effective monthly quota.
Effective quota comes from the (employee, current store) row and nothing
else: the limit is configured per store, so a store without a row of its own
simply has no limit for this employee, and value NULL means "explicitly
unlimited". There is deliberately no fallback to the employee's other rows -
the store the employee belongs to now decides, which is what "the limit
follows the store" means; usage still accumulates across stores.
"""
store = aliased(Scope)
current = aliased(ScopeQuota)
usage = aliased(ScopeMonthUsage)
stmt = (
select(
Scope.id.label("scope_id"),
Scope.account_id.label("account_id"),
Scope.scope_type.label("scope_type"),
Scope.status.label("scope_status"),
Scope.sub_account_id.label("store_bucket"),
store.id.label("parent_id"),
store.scope_type.label("parent_type"),
store.status.label("parent_status"),
store.sub_account_id.label("parent_bucket"),
current.monthly_quota_point_units.label("current_quota"),
func.coalesce(usage.used_point_units, 0).label("used_point_units"),
)
.select_from(Scope)
.outerjoin(store, store.id == Scope.parent_scope_id)
.outerjoin(
current,
and_(current.scope_id == Scope.id, current.store_scope_id == Scope.parent_scope_id),
)
.outerjoin(usage, and_(usage.scope_id == Scope.id, usage.month == month))
.where(Scope.id == scope_id)
)
return session.execute(stmt).first()
def upsert_month_usage(session, scope_id, month, units, *, now=None):
"""Atomically add ``units`` to an employee's month bucket.
The month is a state key, so a new month starts from zero without any reset
job; the atomic UPSERT (never read-then-insert) keeps two concurrent
settlements that both land in a brand new month from racing the unique key.
"""
if units <= 0:
return
now = now or utc_now()
table = ScopeMonthUsage.__table__
dialect = session.get_bind().dialect.name
if dialect == "mysql":
from sqlalchemy.dialects.mysql import insert as dialect_insert
stmt = dialect_insert(table).values(
scope_id=scope_id, month=month, used_point_units=units,
create_time=now, last_update_time=now,
)
stmt = stmt.on_duplicate_key_update(
used_point_units=table.c.used_point_units + stmt.inserted.used_point_units,
last_update_time=stmt.inserted.last_update_time,
)
elif dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert as dialect_insert
stmt = dialect_insert(table).values(
scope_id=scope_id, month=month, used_point_units=units,
create_time=now, last_update_time=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[table.c.scope_id, table.c.month],
set_={
"used_point_units": table.c.used_point_units + stmt.excluded.used_point_units,
"last_update_time": stmt.excluded.last_update_time,
},
)
else:
raise RuntimeError("unsupported dialect: %s" % dialect)
session.execute(stmt)
def find_call(session, account_id, client_id, request_id): def find_call(session, account_id, client_id, request_id):
return session.scalar( return session.scalar(
select(Call).where( select(Call).where(
......
...@@ -50,6 +50,14 @@ class CreateAccountRequest(WriteRequest): ...@@ -50,6 +50,14 @@ class CreateAccountRequest(WriteRequest):
class IssueCredentialRequest(WriteRequest): class IssueCredentialRequest(WriteRequest):
client_id: Optional[ClientId] = None client_id: Optional[ClientId] = None
sub_account_id: Optional[Identifier] = None
scope_id: Optional[Identifier] = None
@model_validator(mode="after")
def exclusive_subject(self):
if self.sub_account_id is not None and self.scope_id is not None:
raise ValueError("subAccountId and scopeId are mutually exclusive")
return self
class ActivateCredentialRequest(WriteRequest): class ActivateCredentialRequest(WriteRequest):
...@@ -80,6 +88,66 @@ class SetAccountStatusRequest(WriteRequest): ...@@ -80,6 +88,66 @@ class SetAccountStatusRequest(WriteRequest):
reason: str = Field(min_length=1, max_length=200) reason: str = Field(min_length=1, max_length=200)
class CreateSubAccountRequest(WriteRequest):
name: str = Field(min_length=1, max_length=100)
remark: Optional[str] = Field(default=None, max_length=500)
@field_validator("name")
@classmethod
def nonblank_sub_account_name(cls, value):
if not value.strip():
raise ValueError("Blank name")
return value
class SetSubAccountStatusRequest(WriteRequest):
status: Literal["ACTIVE", "DISABLED"]
reason: str = Field(min_length=1, max_length=200)
class TransferRequest(WriteRequest):
points: int = Field(ge=1, le=1000000000)
direction: Literal["IN", "OUT"]
operator_note: Optional[str] = Field(default=None, max_length=200)
ScopeKey = Annotated[str, StringConstraints(strict=True, pattern=r"^[a-z0-9-]{1,30}:[A-Za-z0-9._:-]{1,69}$")]
class BindScopeRequest(WriteRequest):
scope_type: Literal["STORE", "EMPLOYEE"]
scope_key: ScopeKey
sub_account_id: Optional[Identifier] = None
parent_scope_key: Optional[ScopeKey] = None
@model_validator(mode="after")
def scope_shape(self):
if self.scope_type == "STORE":
if self.parent_scope_key is not None:
raise ValueError("Store scopes have no parent scope")
elif self.parent_scope_key is None:
raise ValueError("Employee scopes require a parent store key")
return self
class SetScopeStatusRequest(WriteRequest):
status: Literal["ACTIVE", "DISABLED"]
reason: str = Field(min_length=1, max_length=200)
class MoveStoreRequest(WriteRequest):
sub_account_id: Optional[Identifier] = None
class MoveEmployeeRequest(WriteRequest):
parent_scope_id: Identifier
class SetScopeQuotaRequest(WriteRequest):
store_scope_id: Identifier
monthly_quota_points: Optional[int] = Field(default=None, ge=0, le=1000000000)
class AccountQueryRequest(AccountRequest): class AccountQueryRequest(AccountRequest):
account_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000) account_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
page: int = Field(default=1, ge=1) page: int = Field(default=1, ge=1)
...@@ -123,12 +191,23 @@ class ReconcileGiftRequest(WriteRequest): ...@@ -123,12 +191,23 @@ class ReconcileGiftRequest(WriteRequest):
return value return value
class ScopeMonthPageRequest(AccountQueryRequest):
owner_client_id: Optional[ClientId] = None
month: Optional[str] = None
sub_account_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
scope_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
store_scope_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
class LedgerPageRequest(AccountQueryRequest): class LedgerPageRequest(AccountQueryRequest):
owner_client_id: Optional[ClientId] = None owner_client_id: Optional[ClientId] = None
client_id: Optional[ClientId] = None client_id: Optional[ClientId] = None
request_id: Optional[RequestId] = None request_id: Optional[RequestId] = None
start: Optional[datetime] = None start: Optional[datetime] = None
end: Optional[datetime] = None end: Optional[datetime] = None
sub_account_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
scope_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
store_scope_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
@field_validator("start", "end", mode="before") @field_validator("start", "end", mode="before")
@classmethod @classmethod
......
...@@ -5,6 +5,7 @@ import secrets ...@@ -5,6 +5,7 @@ import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
from .account_models import Account, AccountClient, Client, Credential from .account_models import Account, AccountClient, Client, Credential
from .errors import AppError from .errors import AppError
...@@ -18,26 +19,37 @@ ERRORS = { ...@@ -18,26 +19,37 @@ ERRORS = {
"CREDENTIAL_REVOKED": (401, "凭证已撤销"), "CREDENTIAL_REVOKED": (401, "凭证已撤销"),
"ACCOUNT_ACCESS_REVOKED": (403, "账户来源授权已撤销"), "ACCOUNT_ACCESS_REVOKED": (403, "账户来源授权已撤销"),
"ACCOUNT_DISABLED": (403, "账户已停用"), "ACCOUNT_DISABLED": (403, "账户已停用"),
"SUB_ACCOUNT_DISABLED": (403, "子账号已停用"),
"SCOPE_DISABLED": (403, "门店或员工已停用"),
"PERMISSION_DENIED": (403, "无权执行此操作"), "PERMISSION_DENIED": (403, "无权执行此操作"),
"ACCOUNT_NOT_FOUND": (404, "账户不存在或不可见"), "ACCOUNT_NOT_FOUND": (404, "账户不存在或不可见"),
"SUB_ACCOUNT_NOT_FOUND": (404, "子账号不存在或不可见"),
"REQUEST_NOT_FOUND": (404, "请求或凭证不存在或不可见"), "REQUEST_NOT_FOUND": (404, "请求或凭证不存在或不可见"),
"FINGERPRINT_MISMATCH": (409, "相同请求号的参数与原请求不一致"), "FINGERPRINT_MISMATCH": (409, "相同请求号的参数与原请求不一致"),
"ACCOUNT_BLOCKED": (409, "当前状态不允许此操作"), "ACCOUNT_BLOCKED": (409, "当前状态不允许此操作"),
"SCOPE_CONFLICT": (409, "门店或员工标识已存在"),
"GIFT_GATE_BUSY": (409, "账户正被其他赠送占用"), "GIFT_GATE_BUSY": (409, "账户正被其他赠送占用"),
"RATE_LIMITED": (429, "请求数量超过处理容量"), "RATE_LIMITED": (429, "请求数量超过处理容量"),
"EMPLOYEE_QUOTA_EXCEEDED": (429, "员工当月额度已用尽"),
"DEPENDENCY_UNAVAILABLE": (503, "依赖尚未就绪"), "DEPENDENCY_UNAVAILABLE": (503, "依赖尚未就绪"),
"SERVICE_UNAVAILABLE": (503, "服务暂不可用,请查询原请求"), "SERVICE_UNAVAILABLE": (503, "服务暂不可用,请查询原请求"),
} }
KEY_PATTERN = re.compile(r"ck-([1-9][0-9]{17})-([A-Za-z0-9_-]{43})\Z") KEY_PATTERN = re.compile(r"ck-([1-9][0-9]{17})-([A-Za-z0-9_-]{43})\Z")
REQUEST_PATTERN = re.compile(r"[!-~]{8,100}\Z") REQUEST_PATTERN = re.compile(r"[!-~]{8,100}\Z")
SCOPE_KEY_PATTERN = re.compile(r"[a-z0-9-]{1,30}:[A-Za-z0-9._:-]{1,69}\Z")
class AccountError(AppError): class AccountError(AppError):
def __init__(self, code, *, reason=None): def __init__(self, code, *, reason=None, data=None):
status, message = ERRORS[code] status, message = ERRORS[code]
super().__init__(code, message) super().__init__(code, message)
self.http_status = status self.http_status = status
self.data = {"reason": reason} if reason else None if data is not None:
self.data = dict(data)
if reason:
self.data.setdefault("reason", reason)
else:
self.data = {"reason": reason} if reason else None
@dataclass(frozen=True) @dataclass(frozen=True)
...@@ -46,12 +58,20 @@ class Principal: ...@@ -46,12 +58,20 @@ class Principal:
client_id: str client_id: str
category: str category: str
account_id: Optional[int] account_id: Optional[int]
sub_account_id: Optional[int] = None
scope_id: Optional[int] = None
def utc_now(): def utc_now():
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def quota_month(now=None):
"""业务月键 '\''YYYY-MM'\'':登记时间按 Asia/Shanghai 派生(P0 §1 唯一时区派生键,
月界 = 北京 0 点 = UTC 前一日 16:00)。月用量、审计与告警必须共用这一个实现。"""
return (now or utc_now()).astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m")
def issue_secret(credential_id): def issue_secret(credential_id):
secret = "ck-%s-%s" % (credential_id, secrets.token_urlsafe(32)) secret = "ck-%s-%s" % (credential_id, secrets.token_urlsafe(32))
return secret, hashlib.sha256(secret.encode("ascii")).hexdigest(), secret[:4] + "****" + secret[-4:] return secret, hashlib.sha256(secret.encode("ascii")).hexdigest(), secret[:4] + "****" + secret[-4:]
...@@ -63,6 +83,15 @@ def validate_request_id(value): ...@@ -63,6 +83,15 @@ def validate_request_id(value):
return value return value
def validate_scope_key(value):
"""Scope keys are opaque source identifiers that must carry a source
namespace prefix (``source:subject``); the prefix is what keeps two
source systems from colliding on the same key."""
if not isinstance(value, str) or not SCOPE_KEY_PATTERN.fullmatch(value):
raise AccountError("INVALID_ARGUMENT", reason="INVALID_SCOPE_KEY")
return value
def authenticate(session, secret, *, now=None): def authenticate(session, secret, *, now=None):
match = KEY_PATTERN.fullmatch(secret) if isinstance(secret, str) else None match = KEY_PATTERN.fullmatch(secret) if isinstance(secret, str) else None
if match is None: if match is None:
...@@ -89,7 +118,8 @@ def authenticate(session, secret, *, now=None): ...@@ -89,7 +118,8 @@ def authenticate(session, secret, *, now=None):
access = session.get(AccountClient, (credential.account_id, credential.client_id)) access = session.get(AccountClient, (credential.account_id, credential.client_id))
if account is None or access is None or access.status != "AUTHORIZED": if account is None or access is None or access.status != "AUTHORIZED":
raise AccountError("ACCOUNT_ACCESS_REVOKED") raise AccountError("ACCOUNT_ACCESS_REVOKED")
return Principal(credential.id, credential.client_id, credential.category, credential.account_id) return Principal(credential.id, credential.client_id, credential.category, credential.account_id,
credential.sub_account_id, credential.scope_id)
def require_management(principal): def require_management(principal):
......
import asyncio import asyncio
import hashlib import hashlib
import json import json
import logging
import time import time
from datetime import timedelta from datetime import timedelta
from threading import BoundedSemaphore from threading import BoundedSemaphore
...@@ -10,11 +11,20 @@ from sqlalchemy.exc import IntegrityError ...@@ -10,11 +11,20 @@ from sqlalchemy.exc import IntegrityError
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from . import account_repository as repository from . import account_repository as repository
from . import account_admission as admission
from .account_gateway import GatewayError from .account_gateway import GatewayError
from .account_models import Account, AccountClient, Call, Client, Credential, ManagementRequest, OperationAudit from .account_models import (
from .account_security import AccountError, authenticate, issue_secret, require_management, utc_now, validate_request_id Account, AccountClient, Call, Client, Credential, ManagementRequest,
OperationAudit, PointRecord, Scope, ScopeQuota, SubAccount,
)
from .account_security import (
AccountError, authenticate, issue_secret, quota_month, require_management, utc_now,
validate_request_id, validate_scope_key,
)
from .billing import EXPECTED_QUOTA_PER_UNIT, format_points from .billing import EXPECTED_QUOTA_PER_UNIT, format_points
logger = logging.getLogger(__name__)
def fingerprint(values): def fingerprint(values):
return hashlib.sha256(json.dumps(values, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest() return hashlib.sha256(json.dumps(values, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
...@@ -24,11 +34,48 @@ def _time(value): ...@@ -24,11 +34,48 @@ def _time(value):
return value.isoformat(timespec="milliseconds").replace("+00:00", "Z") if value else None return value.isoformat(timespec="milliseconds").replace("+00:00", "Z") if value else None
def credential_view(row): def sub_account_view(row):
return {
"subAccountId": str(row.id), "accountId": str(row.account_id),
"name": row.name, "remark": row.remark, "status": row.status,
"balancePointUnits": str(row.balance_point_units),
"balancePoints": format_points(row.balance_point_units),
"createTime": _time(row.create_time),
}
def scope_view(row):
return {
"scopeId": str(row.id), "accountId": str(row.account_id), "scopeType": row.scope_type,
"scopeKey": row.scope_key, "status": row.status,
"subAccountId": str(row.sub_account_id) if row.sub_account_id is not None else None,
"parentScopeId": str(row.parent_scope_id) if row.parent_scope_id is not None else None,
"createTime": _time(row.create_time),
}
def quota_view(row):
units = row.monthly_quota_point_units
return {
"quotaId": str(row.id), "scopeId": str(row.scope_id), "storeScopeId": str(row.store_scope_id),
"monthlyQuotaPointUnits": str(units) if units is not None else None,
"monthlyQuotaPoints": format_points(units) if units is not None else None,
"createTime": _time(row.create_time),
}
def credential_view(row, scope_types=None):
"""凭证视图。`scope_types` 是 scopeId → scopeType 的映射(可选,缺省时 scopeType 为 null)。"""
scope_type = None
if row.scope_id is not None and scope_types:
scope_type = scope_types.get(int(row.scope_id))
return { return {
"credentialId": str(row.id), "clientId": row.client_id, "credentialId": str(row.id), "clientId": row.client_id,
"accountId": str(row.account_id) if row.account_id is not None else None, "accountId": str(row.account_id) if row.account_id is not None else None,
"category": row.category, "status": row.status, "secretMask": row.secret_mask, "category": row.category, "status": row.status, "secretMask": row.secret_mask,
"subAccountId": str(row.sub_account_id) if row.sub_account_id is not None else None,
"scopeId": str(row.scope_id) if row.scope_id is not None else None,
"scopeType": scope_type,
"pendingExpiresAt": _time(row.pending_expires_at), "validUntil": _time(row.valid_until), "pendingExpiresAt": _time(row.pending_expires_at), "validUntil": _time(row.valid_until),
"activatedAt": _time(row.activated_at), "activatedAt": _time(row.activated_at),
"replacedBy": str(row.replaced_by) if row.replaced_by is not None else None, "replacedBy": str(row.replaced_by) if row.replaced_by is not None else None,
...@@ -36,6 +83,14 @@ def credential_view(row): ...@@ -36,6 +83,14 @@ def credential_view(row):
} }
def _scope_types(session, scope_ids):
"""批量取 scopeId → scopeType,供凭证出参辨认绑定主体。"""
ids = {int(value) for value in scope_ids if value is not None}
if not ids:
return {}
return dict(session.execute(select(Scope.id, Scope.scope_type).where(Scope.id.in_(ids))).all())
def _account_scope(session, principal, account): def _account_scope(session, principal, account):
if account is None: if account is None:
raise AccountError("ACCOUNT_NOT_FOUND") raise AccountError("ACCOUNT_NOT_FOUND")
...@@ -82,7 +137,24 @@ class AccountService: ...@@ -82,7 +137,24 @@ class AccountService:
account = session.get(Account, row.target_id) account = session.get(Account, row.target_id)
data.update(accountId=str(account.id), provisionStatus=account.provision_status) data.update(accountId=str(account.id), provisionStatus=account.provision_status)
elif row.operation_type in {"ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL", "REVOKE_CREDENTIAL"}: elif row.operation_type in {"ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL", "REVOKE_CREDENTIAL"}:
data.update(credential_view(session.get(Credential, row.target_id))) credential = session.get(Credential, row.target_id)
data.update(credential_view(credential, _scope_types(session, [credential.scope_id])))
elif row.operation_type in {"CREATE_SUB_ACCOUNT", "SET_SUB_ACCOUNT_STATUS", "TRANSFER"}:
sub = session.get(SubAccount, row.target_id)
if sub is not None:
data.update(subAccountId=str(sub.id), status=sub.status,
balancePointUnits=str(sub.balance_point_units))
data.update(row.result_ref or {})
elif row.operation_type in {"BIND_SCOPE", "SET_SCOPE_STATUS", "MOVE_STORE", "MOVE_EMPLOYEE"}:
scope = session.get(Scope, row.target_id)
if scope is not None:
data.update(scope_view(scope))
data.update(row.result_ref or {})
elif row.operation_type == "SET_SCOPE_QUOTA":
quota = session.get(ScopeQuota, row.target_id)
if quota is not None:
data.update(quota_view(quota))
data.update(row.result_ref or {})
elif row.operation_type == "RECONCILE" and (row.result_ref or {}).get("giftId"): elif row.operation_type == "RECONCILE" and (row.result_ref or {}).get("giftId"):
from .account_ledger import gift_view from .account_ledger import gift_view
from .account_models import Gift from .account_models import Gift
...@@ -99,60 +171,83 @@ class AccountService: ...@@ -99,60 +171,83 @@ class AccountService:
).with_for_update()) ).with_for_update())
def _audit(self, session, id, principal, operation, request_id, target_type, target_id, def _audit(self, session, id, principal, operation, request_id, target_type, target_id,
account_id=None, reason=None, from_status=None, to_status=None): account_id=None, reason=None, from_status=None, to_status=None, evidence_ref=None):
session.add(OperationAudit( session.add(OperationAudit(
id=id, actor_credential_id=principal.credential_id, client_id=principal.client_id, id=id, actor_credential_id=principal.credential_id, client_id=principal.client_id,
account_id=account_id, action=operation, target_type=target_type, target_id=target_id, account_id=account_id, action=operation, target_type=target_type, target_id=target_id,
request_id=request_id, reason=reason or operation, request_id=request_id, reason=reason or operation,
from_status=from_status, to_status=to_status, from_status=from_status, to_status=to_status,
evidence_ref=evidence_ref,
)) ))
def _guard(self, session, secret, operation, request_id, digest, parameters, account_id,
credential_id, platform_only, *, lock):
"""鉴权、目标解析与幂等回查(`_mutate` 与开户共用)。
返回 `(principal, account, target, account_id, row)`;`row` 非空表示这是重放。
`lock=True` 走业务锁(写事务内),`lock=False` 只做只读确认(取号前的预检查)。
"""
principal = authenticate(session, secret)
require_management(principal)
if platform_only and principal.category != "PLATFORM":
raise AccountError("PERMISSION_DENIED")
if operation == "ISSUE_CALL_KEY":
_client_scope(principal, parameters.get("clientId"))
target = None
if credential_id is not None:
target = session.get(Credential, credential_id)
if target is None:
raise AccountError("REQUEST_NOT_FOUND")
client_id = _client_scope(principal, parameters.get("clientId"))
if target.client_id != client_id:
raise AccountError("REQUEST_NOT_FOUND")
if principal.category == "INTEGRATION" and target.category != "CALL":
raise AccountError("PERMISSION_DENIED")
account_id = target.account_id
if lock:
account = repository.lock_account(session, account_id) if account_id is not None else None
else:
account = session.get(Account, account_id) if account_id is not None else None
if account_id is not None:
_account_scope(session, principal, account)
row = self._request(session, principal, operation, request_id)
if row is not None:
if row.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH")
return principal, account, target, account_id, row
def _mutate(self, secret, request_id, operation, parameters, action, *, account_id=None, def _mutate(self, secret, request_id, operation, parameters, action, *, account_id=None,
credential_id=None, platform_only=False): credential_id=None, platform_only=False, extra_ids=0, audit_evidence=None):
validate_request_id(request_id) validate_request_id(request_id)
require_management(self.identity(secret)) require_management(self.identity(secret))
digest = fingerprint(parameters) digest = fingerprint(parameters)
for attempt in range(2): for attempt in range(2):
try: try:
# 号段在业务锁之前领取(P0 契约):先做只读确认,命中的重放直接回执
# (重放不依赖发号器),确认是新操作才取号,然后进业务事务重做一遍检查。
with self.factory() as probe:
_, _, _, _, replayed = self._guard(
probe, secret, operation, request_id, digest, parameters, account_id,
credential_id, platform_only, lock=False)
if replayed is not None:
return self._view(probe, replayed)
ids = self._ids(3 + extra_ids)
with self.factory.begin() as session: with self.factory.begin() as session:
principal = authenticate(session, secret) principal, account, target, account_id, row = self._guard(
require_management(principal) session, secret, operation, request_id, digest, parameters, account_id,
if platform_only and principal.category != "PLATFORM": credential_id, platform_only, lock=True)
raise AccountError("PERMISSION_DENIED")
if operation == "ISSUE_CALL_KEY":
_client_scope(principal, parameters.get("clientId"))
target = None
if credential_id is not None:
target = session.get(Credential, credential_id)
if target is None:
raise AccountError("REQUEST_NOT_FOUND")
client_id = _client_scope(principal, parameters.get("clientId"))
if target.client_id != client_id:
raise AccountError("REQUEST_NOT_FOUND")
if principal.category == "INTEGRATION" and target.category != "CALL":
raise AccountError("PERMISSION_DENIED")
account_id = target.account_id
account = repository.lock_account(session, account_id) if account_id is not None else None
if account_id is not None:
_account_scope(session, principal, account)
row = self._request(session, principal, operation, request_id)
if row is not None: if row is not None:
if row.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH")
return self._view(session, row) return self._view(session, row)
# Replay must not depend on the ID segment; allocate only now
# that a new row is actually required.
ids = self._ids(3)
row = ManagementRequest( row = ManagementRequest(
id=ids[0], client_id=principal.client_id, actor_credential_id=principal.credential_id, id=ids[0], client_id=principal.client_id, actor_credential_id=principal.credential_id,
operation_type=operation, request_id=request_id, fingerprint=digest, operation_type=operation, request_id=request_id, fingerprint=digest,
) )
session.add(row) session.add(row)
session.flush() session.flush()
target_type, target_id, result, raw_secret = action(session, principal, account, target, ids[1]) target_type, target_id, result, raw_secret = action(session, principal, account, target, ids[1:])
row.target_id, row.status, row.result_ref = target_id, "SUCCEEDED", result row.target_id, row.status, row.result_ref = target_id, "SUCCEEDED", result
self._audit(session, ids[2], principal, operation, request_id, target_type, target_id, self._audit(session, ids[2], principal, operation, request_id, target_type, target_id,
account_id, parameters.get("reason")) account_id, parameters.get("reason"), evidence_ref=audit_evidence)
session.flush() session.flush()
data = self._view(session, row) data = self._view(session, row)
if raw_secret is not None: if raw_secret is not None:
...@@ -163,8 +258,10 @@ class AccountService: ...@@ -163,8 +258,10 @@ class AccountService:
raise AccountError("SERVICE_UNAVAILABLE") from None raise AccountError("SERVICE_UNAVAILABLE") from None
raise AccountError("SERVICE_UNAVAILABLE") raise AccountError("SERVICE_UNAVAILABLE")
def issue_credential(self, secret, request_id, account_id, client_id=None): def issue_credential(self, secret, request_id, account_id, client_id=None,
def action(session, principal, account, target, id): sub_account_id=None, scope_id=None):
def action(session, principal, account, target, ids):
id = ids[0]
target_client = _client_scope(principal, client_id) target_client = _client_scope(principal, client_id)
client = session.get(Client, target_client) client = session.get(Client, target_client)
access = session.get(AccountClient, (account.id, target_client)) access = session.get(AccountClient, (account.id, target_client))
...@@ -174,18 +271,39 @@ class AccountService: ...@@ -174,18 +271,39 @@ class AccountService:
raise AccountError("ACCOUNT_ACCESS_REVOKED") raise AccountError("ACCOUNT_ACCESS_REVOKED")
if account.status != "ACTIVE": if account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED") raise AccountError("ACCOUNT_DISABLED")
if sub_account_id is not None:
sub = repository.find_sub_account(session, account.id, sub_account_id)
if sub is None:
raise AccountError("SUB_ACCOUNT_NOT_FOUND")
if scope_id is not None:
scope = repository.find_scope(session, account.id, scope_id)
if scope is None:
raise AccountError("REQUEST_NOT_FOUND", reason="SCOPE_NOT_FOUND")
if scope.status != "ACTIVE":
raise AccountError("SCOPE_DISABLED")
if sub_account_id is None and scope_id is None and principal.category != "PLATFORM":
# An account-level key drains the account's unallocated bucket and
# answers to no scope at all, so it stays a platform/testing
# facility. A merchant calls through a store or employee key; a
# store bound straight to the account (subAccountId NULL) already
# covers the "no sub-account" case without one.
raise AccountError("PERMISSION_DENIED", reason="ACCOUNT_KEY_PLATFORM_ONLY")
raw, digest, mask = issue_secret(id) raw, digest, mask = issue_secret(id)
session.add(Credential( session.add(Credential(
id=id, category="CALL", client_id=target_client, account_id=account.id, id=id, category="CALL", client_id=target_client, account_id=account.id,
sub_account_id=sub_account_id, scope_id=scope_id,
secret_digest=digest, secret_mask=mask, status="PENDING", secret_digest=digest, secret_mask=mask, status="PENDING",
pending_expires_at=utc_now() + timedelta(hours=24), issued_by=principal.credential_id, pending_expires_at=utc_now() + timedelta(hours=24), issued_by=principal.credential_id,
)) ))
return "CREDENTIAL", id, {"credentialId": str(id)}, raw return "CREDENTIAL", id, {"credentialId": str(id)}, raw
return self._mutate(secret, request_id, "ISSUE_CALL_KEY", return self._mutate(secret, request_id, "ISSUE_CALL_KEY",
{"accountId": str(account_id), "clientId": client_id}, action, account_id=account_id) {"accountId": str(account_id), "clientId": client_id,
"subAccountId": str(sub_account_id) if sub_account_id is not None else None,
"scopeId": str(scope_id) if scope_id is not None else None},
action, account_id=account_id)
def activate_credential(self, secret, request_id, credential_id, client_id=None, replaces_id=None): def activate_credential(self, secret, request_id, credential_id, client_id=None, replaces_id=None):
def action(session, principal, account, target, id): def action(session, principal, account, target, ids):
if target.category != "CALL": if target.category != "CALL":
raise AccountError("PERMISSION_DENIED") raise AccountError("PERMISSION_DENIED")
if account.status != "ACTIVE": if account.status != "ACTIVE":
...@@ -228,7 +346,7 @@ class AccountService: ...@@ -228,7 +346,7 @@ class AccountService:
action, credential_id=credential_id) action, credential_id=credential_id)
def revoke_credential(self, secret, request_id, credential_id, reason, client_id=None): def revoke_credential(self, secret, request_id, credential_id, reason, client_id=None):
def action(session, principal, account, target, id): def action(session, principal, account, target, ids):
current = session.get(Credential, credential_id, with_for_update=True, populate_existing=True) current = session.get(Credential, credential_id, with_for_update=True, populate_existing=True)
current.status, current.revoke_reason = "REVOKED", reason current.status, current.revoke_reason = "REVOKED", reason
return "CREDENTIAL", current.id, {"credentialId": str(current.id)}, None return "CREDENTIAL", current.id, {"credentialId": str(current.id)}, None
...@@ -237,7 +355,7 @@ class AccountService: ...@@ -237,7 +355,7 @@ class AccountService:
action, credential_id=credential_id) action, credential_id=credential_id)
def set_client(self, secret, request_id, account_id, client_id, status, reason): def set_client(self, secret, request_id, account_id, client_id, status, reason):
def action(session, principal, account, target, id): def action(session, principal, account, target, ids):
client = session.get(Client, client_id) client = session.get(Client, client_id)
if client is None or (status == "AUTHORIZED" and client.status != "ACTIVE"): if client is None or (status == "AUTHORIZED" and client.status != "ACTIVE"):
raise AccountError("PERMISSION_DENIED") raise AccountError("PERMISSION_DENIED")
...@@ -253,7 +371,7 @@ class AccountService: ...@@ -253,7 +371,7 @@ class AccountService:
action, account_id=account_id, platform_only=True) action, account_id=account_id, platform_only=True)
def set_status(self, secret, request_id, account_id, status, reason): def set_status(self, secret, request_id, account_id, status, reason):
def action(session, principal, account, target, id): def action(session, principal, account, target, ids):
if status == "ACTIVE" and (account.provision_status != "READY" or account.gift_gate is not None if status == "ACTIVE" and (account.provision_status != "READY" or account.gift_gate is not None
or repository.count_uncertain_calls(session, account.id)): or repository.count_uncertain_calls(session, account.id)):
raise AccountError("ACCOUNT_BLOCKED", reason="UNRESOLVED_OPERATION") raise AccountError("ACCOUNT_BLOCKED", reason="UNRESOLVED_OPERATION")
...@@ -263,47 +381,346 @@ class AccountService: ...@@ -263,47 +381,346 @@ class AccountService:
{"accountId": str(account_id), "status": status, "reason": reason}, {"accountId": str(account_id), "status": status, "reason": reason},
action, account_id=account_id, platform_only=True) action, account_id=account_id, platform_only=True)
def list_credentials(self, secret, account_id, client_id=None): def create_sub_account(self, secret, request_id, account_id, name, remark=None):
def action(session, principal, account, target, ids):
if account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED")
from .account_models import SubAccount
session.add(SubAccount(id=ids[0], account_id=account.id, name=name, remark=remark))
session.flush()
return "SUB_ACCOUNT", ids[0], sub_account_view(session.get(SubAccount, ids[0])), None
return self._mutate(secret, request_id, "CREATE_SUB_ACCOUNT",
{"accountId": str(account_id), "name": name},
action, account_id=account_id)
def set_sub_account_status(self, secret, request_id, account_id, sub_account_id, status, reason):
def action(session, principal, account, target, ids):
sub = repository.find_sub_account(session, account.id, sub_account_id)
if sub is None:
raise AccountError("SUB_ACCOUNT_NOT_FOUND")
locked = repository.lock_sub_account(session, sub.id)
if status == "ACTIVE" and account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED")
locked.status = status
return "SUB_ACCOUNT", locked.id, {"subAccountId": str(locked.id), "status": status}, None
return self._mutate(secret, request_id, "SET_SUB_ACCOUNT_STATUS",
{"subAccountId": str(sub_account_id), "status": status, "reason": reason},
action, account_id=account_id)
def transfer(self, secret, request_id, account_id, sub_account_id, points, direction, operator_note=None):
def action(session, principal, account, target, ids):
sub = repository.find_sub_account(session, account.id, sub_account_id)
if sub is None:
raise AccountError("SUB_ACCOUNT_NOT_FOUND")
# Lock order: the account row is already locked by _mutate; take the
# sub-account row second.
locked_sub = repository.lock_sub_account(session, sub.id)
if account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED")
if locked_sub.status != "ACTIVE":
raise AccountError("SUB_ACCOUNT_DISABLED")
units = points * 10000
if direction == "OUT":
source, target_row, out_bucket, in_bucket = account, locked_sub, None, locked_sub.id
else:
source, target_row, out_bucket, in_bucket = locked_sub, account, locked_sub.id, None
if source.balance_point_units < units:
raise AccountError("ACCOUNT_BLOCKED", reason="INSUFFICIENT_BALANCE",
data={"subAccountId": str(locked_sub.id)})
source_before = source.balance_point_units
target_before = target_row.balance_point_units
source.balance_point_units = source_before - units
target_row.balance_point_units = target_before + units
source.version += 1
if target_row is not account:
target_row.version += 1
session.add_all([
PointRecord(
id=ids[0], account_id=account.id, client_id=principal.client_id,
type="TRANSFER_OUT", point_units=-units,
balance_before_units=source_before, balance_after_units=source_before - units,
sub_account_id=out_bucket, request_id=request_id, remark=operator_note,
),
PointRecord(
id=ids[1], account_id=account.id, client_id=principal.client_id,
type="TRANSFER_IN", point_units=units,
balance_before_units=target_before, balance_after_units=target_before + units,
sub_account_id=in_bucket, request_id=request_id, remark=operator_note,
),
])
return "SUB_ACCOUNT", locked_sub.id, {
"subAccountId": str(locked_sub.id), "direction": direction,
"points": points, "balancePointUnits": str(locked_sub.balance_point_units),
}, None
return self._mutate(secret, request_id, "TRANSFER",
{"accountId": str(account_id), "subAccountId": str(sub_account_id),
"points": points, "direction": direction},
action, account_id=account_id, extra_ids=2)
def bind_scope(self, secret, request_id, account_id, scope_type, scope_key,
sub_account_id=None, parent_scope_key=None):
def action(session, principal, account, target, ids):
validate_scope_key(scope_key)
if account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED")
# Global uniqueness: the same store key can never be bound twice, not
# even under another account - that is what makes a store belong to
# exactly one account. Re-binding a store to another bucket is a
# MOVE_STORE, never a second BIND_SCOPE.
if repository.find_scope_by_key(session, scope_type, scope_key) is not None:
raise AccountError("SCOPE_CONFLICT")
if scope_type == "STORE":
if sub_account_id is not None:
sub = repository.find_sub_account(session, account.id, sub_account_id)
if sub is None:
raise AccountError("SUB_ACCOUNT_NOT_FOUND")
if sub.status != "ACTIVE":
raise AccountError("SUB_ACCOUNT_DISABLED")
row = Scope(id=ids[0], account_id=account.id, sub_account_id=sub_account_id,
scope_type="STORE", scope_key=scope_key)
else:
parent = repository.find_scope_by_key(session, "STORE", parent_scope_key) \
if isinstance(parent_scope_key, str) else None
if parent is None or parent.account_id != account.id:
raise AccountError("REQUEST_NOT_FOUND", reason="PARENT_SCOPE_NOT_FOUND")
if parent.status != "ACTIVE":
raise AccountError("SCOPE_DISABLED", reason="PARENT_SCOPE_DISABLED")
row = Scope(id=ids[0], account_id=account.id, scope_type="EMPLOYEE",
scope_key=scope_key, parent_scope_id=parent.id)
session.add(row)
session.flush()
return "SCOPE", row.id, scope_view(row), None
return self._mutate(secret, request_id, "BIND_SCOPE",
{"accountId": str(account_id), "scopeType": scope_type,
"scopeKey": scope_key,
"subAccountId": str(sub_account_id) if sub_account_id is not None else None,
"parentScopeKey": parent_scope_key},
action, account_id=account_id)
def set_scope_status(self, secret, request_id, account_id, scope_id, status, reason):
def action(session, principal, account, target, ids):
found = repository.find_scope(session, account.id, scope_id)
if found is None:
raise AccountError("REQUEST_NOT_FOUND", reason="SCOPE_NOT_FOUND")
row = repository.lock_scope(session, found.id)
if status == "ACTIVE" and account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED")
previous = row.status
row.status = status
row.version += 1
return "SCOPE", row.id, dict(scope_view(row), fromStatus=previous), None
return self._mutate(secret, request_id, "SET_SCOPE_STATUS",
{"scopeId": str(scope_id), "status": status, "reason": reason},
action, account_id=account_id)
def move_store(self, secret, request_id, account_id, scope_id, sub_account_id):
def action(session, principal, account, target, ids):
found = repository.find_scope(session, account.id, scope_id)
if found is None or found.scope_type != "STORE":
raise AccountError("REQUEST_NOT_FOUND", reason="STORE_SCOPE_NOT_FOUND")
row = repository.lock_scope(session, found.id)
if sub_account_id is not None:
sub = repository.find_sub_account(session, account.id, sub_account_id)
if sub is None:
raise AccountError("SUB_ACCOUNT_NOT_FOUND")
if sub.status != "ACTIVE":
raise AccountError("SUB_ACCOUNT_DISABLED")
# Re-assignment moves no balance at all (a store holds none); the
# store key keeps resolving to whatever bucket is current.
previous = row.sub_account_id
row.sub_account_id = sub_account_id
row.version += 1
session.flush()
return "SCOPE", row.id, dict(
scope_view(row),
previousSubAccountId=str(previous) if previous is not None else None), None
return self._mutate(secret, request_id, "MOVE_STORE",
{"scopeId": str(scope_id),
"subAccountId": str(sub_account_id) if sub_account_id is not None else None},
action, account_id=account_id)
def move_employee(self, secret, request_id, account_id, scope_id, parent_scope_id):
def action(session, principal, account, target, ids):
found = repository.find_scope(session, account.id, scope_id)
if found is None or found.scope_type != "EMPLOYEE":
raise AccountError("REQUEST_NOT_FOUND", reason="EMPLOYEE_SCOPE_NOT_FOUND")
row = repository.lock_scope(session, found.id)
store = repository.find_scope(session, account.id, parent_scope_id)
if store is None or store.scope_type != "STORE":
raise AccountError("REQUEST_NOT_FOUND", reason="PARENT_SCOPE_NOT_FOUND")
if store.status != "ACTIVE":
raise AccountError("SCOPE_DISABLED", reason="PARENT_SCOPE_DISABLED")
# The month's usage deliberately stays where it is: the employee
# carries what has already been consumed into the new store, and the
# limit from then on is whatever the new store configures.
previous = row.parent_scope_id
row.parent_scope_id = store.id
row.version += 1
return "SCOPE", row.id, dict(
scope_view(row),
previousParentScopeId=str(previous) if previous is not None else None), None
return self._mutate(secret, request_id, "MOVE_EMPLOYEE",
{"scopeId": str(scope_id), "parentScopeId": str(parent_scope_id)},
action, account_id=account_id)
def set_scope_quota(self, secret, request_id, account_id, scope_id, store_scope_id,
monthly_quota_points):
evidence = {}
def action(session, principal, account, target, ids):
found = repository.find_scope(session, account.id, scope_id)
if found is None or found.scope_type != "EMPLOYEE":
raise AccountError("REQUEST_NOT_FOUND", reason="EMPLOYEE_SCOPE_NOT_FOUND")
store = repository.find_scope(session, account.id, store_scope_id)
if store is None or store.scope_type != "STORE":
raise AccountError("REQUEST_NOT_FOUND", reason="PARENT_SCOPE_NOT_FOUND")
repository.lock_scope(session, found.id)
units = None if monthly_quota_points is None else monthly_quota_points * 10000
row = repository.find_scope_quota(session, found.id, store.id)
if row is None:
row = ScopeQuota(id=ids[0], scope_id=found.id, store_scope_id=store.id,
monthly_quota_point_units=units)
session.add(row)
previous = None
else:
previous = row.monthly_quota_point_units
row.monthly_quota_point_units = units
row.version += 1
session.flush()
# Audit keeps the before/after values plus the row's own update
# stamp: replaying this audit log rebuilds a historical month's
# effective quota without a snapshot table (design §3.3).
evidence.update({
"scopeId": str(row.scope_id), "storeScopeId": str(row.store_scope_id),
"beforeQuotaPointUnits": str(previous) if previous is not None else None,
"afterQuotaPointUnits": str(row.monthly_quota_point_units)
if row.monthly_quota_point_units is not None else None,
"rowLastUpdateTime": _time(row.last_update_time),
})
# S16:允许调到低于已用(拒新调用直到上调或次月),但这是个风险状态,
# 必须留下可设阈值的结构化告警,否则只能靠报表才发现。
if row.monthly_quota_point_units is not None:
month = quota_month()
used = repository.find_month_usage(session, row.scope_id, month)
if used >= row.monthly_quota_point_units:
logger.warning("scope quota below current month usage, accountId=%s scopeId=%s "
"storeScopeId=%s quotaMonth=%s limitPointUnits=%s usedPointUnits=%s",
account.id, row.scope_id, row.store_scope_id, month,
row.monthly_quota_point_units, used)
return "SCOPE_QUOTA", row.id, quota_view(row), None
return self._mutate(secret, request_id, "SET_SCOPE_QUOTA",
{"scopeId": str(scope_id), "storeScopeId": str(store_scope_id),
"monthlyQuotaPoints": monthly_quota_points},
action, account_id=account_id, audit_evidence=evidence)
def list_scopes(self, secret, account_id, scope_type=None, sub_account_id=None, page=1, size=20):
with self.factory() as session:
principal = authenticate(session, secret)
require_management(principal)
account = session.get(Account, account_id)
_account_scope(session, principal, account)
total, rows = repository.page_scopes(session, account.id, scope_type=scope_type,
sub_account_id=sub_account_id, page=page, size=size)
return {"total": total, "list": [scope_view(row) for row in rows],
"page": page, "size": size}
def list_sub_accounts(self, secret, account_id, page=1, size=20):
with self.factory() as session:
principal = authenticate(session, secret)
require_management(principal)
account = session.get(Account, account_id)
_account_scope(session, principal, account)
total, rows = repository.page_sub_accounts(session, account.id, page=page, size=size)
return {"total": total, "list": [sub_account_view(row) for row in rows],
"page": page, "size": size}
def list_credentials(self, secret, account_id, client_id=None, *, status=None,
sub_account_id=None, scope_id=None, page=1, size=20):
"""凭证列表:分页 + status/subAccountId/scopeId 筛选。
修复前是"取 201 条,超过 200 直接 400",撤销的凭证也计数——"每门店/员工一把 Key"
加轮换很容易越线,列表就成了不可用接口。
"""
if page < 1 or not 1 <= size <= 200:
raise AccountError("INVALID_ARGUMENT")
with self.factory() as session: with self.factory() as session:
principal = authenticate(session, secret) principal = authenticate(session, secret)
require_management(principal) require_management(principal)
account = session.get(Account, account_id) account = session.get(Account, account_id)
_account_scope(session, principal, account) _account_scope(session, principal, account)
target_client = _client_scope(principal, client_id) target_client = _client_scope(principal, client_id)
rows = session.scalars(select(Credential).where( conditions = [Credential.account_id == account_id, Credential.client_id == target_client]
Credential.account_id == account_id, Credential.client_id == target_client, if status is not None:
).order_by(Credential.create_time.desc(), Credential.id.desc()).limit(201)).all() conditions.append(Credential.status == status)
if len(rows) > 200: if sub_account_id is not None:
raise AccountError("INVALID_ARGUMENT", reason="CREDENTIAL_LIST_LIMIT") conditions.append(Credential.sub_account_id == int(sub_account_id))
return {"list": [credential_view(row) for row in rows]} if scope_id is not None:
conditions.append(Credential.scope_id == int(scope_id))
def _account_view(self, session, account, *, uncertain=None): total = session.scalar(select(func.count()).select_from(Credential).where(*conditions))
rows = session.scalars(select(Credential).where(*conditions).order_by(
Credential.create_time.desc(), Credential.id.desc(),
).offset((page - 1) * size).limit(size)).all()
scope_types = _scope_types(session, [row.scope_id for row in rows])
return {"total": total, "page": page, "size": size,
"list": [credential_view(row, scope_types) for row in rows]}
def _account_view(self, session, account, *, principal=None, uncertain=None,
include_hierarchy=False, month=None):
"""账户视图。带 CALL 主体时按**该 Key 的主体口径**判定(与调用准入共用规则):
blockedReasons 覆盖子账号桶、门店/员工状态、员工月度额度,balancePointUnits 是
这个 Key 真正扣费的那个桶的余额。
"""
verdict = None
if principal is not None and principal.category == "CALL":
verdict = admission.evaluate(
session, account, principal=principal,
month=month if month is not None else quota_month(), uncertain=uncertain,
binding_error=admission.binding_error(account, self.settings))
reasons = [] reasons = []
if account.status != "ACTIVE": if account.status != "ACTIVE":
reasons.append("ACCOUNT_DISABLED") reasons.append("ACCOUNT_DISABLED")
if account.provision_status != "READY": if account.provision_status != "READY":
reasons.append("ACCOUNT_NOT_READY") reasons.append("ACCOUNT_NOT_READY")
if account.balance_point_units <= 0:
reasons.append("INSUFFICIENT_BALANCE")
if account.gift_gate is not None:
reasons.append("GIFT_GATE_BUSY")
if uncertain is None: if uncertain is None:
uncertain = repository.count_uncertain_calls(session, account.id) > 0 uncertain = repository.count_uncertain_calls(session, account.id) > 0
if uncertain: if uncertain:
reasons.append("UNRESOLVED_CALL") reasons.append("UNRESOLVED_CALL")
return { if account.gift_gate is not None:
reasons.append("GIFT_GATE_BUSY")
if verdict is not None:
reasons.extend(verdict.reasons)
reasons = list(dict.fromkeys(reasons))
balance = account.balance_point_units if verdict is None else verdict.balance_point_units
data = {
"accountId": str(account.id), "name": account.name, "remark": account.remark, "accountId": str(account.id), "name": account.name, "remark": account.remark,
"ownerClientId": account.owner_client_id, "status": account.status, "ownerClientId": account.owner_client_id, "status": account.status,
"provisionStatus": account.provision_status, "balancePointUnits": str(account.balance_point_units), "provisionStatus": account.provision_status, "balancePointUnits": str(balance),
"balancePoints": format_points(account.balance_point_units), "available": not reasons, "blockedReasons": reasons, "balancePoints": format_points(balance), "available": not reasons, "blockedReasons": reasons,
"bucketType": admission.ACCOUNT_BUCKET if verdict is None else verdict.bucket_type,
"bucketId": None if verdict is None or verdict.bucket_id is None else str(verdict.bucket_id),
"subAccountId": None if principal is None or principal.sub_account_id is None else str(principal.sub_account_id),
"scopeId": None if principal is None or principal.scope_id is None else str(principal.scope_id),
} }
if include_hierarchy:
subs = session.scalars(select(SubAccount).where(
SubAccount.account_id == account.id,
).order_by(SubAccount.create_time.desc(), SubAccount.id.desc())).all()
data["unallocatedPointUnits"] = str(account.balance_point_units)
data["subAccounts"] = [{
"subAccountId": str(sub.id), "name": sub.name, "status": sub.status,
"balancePointUnits": str(sub.balance_point_units),
"balancePoints": format_points(sub.balance_point_units),
} for sub in subs]
return data
def current_account(self, secret): def current_account(self, secret):
with self.factory() as session: with self.factory() as session:
principal = authenticate(session, secret) principal = authenticate(session, secret)
if principal.category != "CALL": if principal.category != "CALL":
raise AccountError("PERMISSION_DENIED") raise AccountError("PERMISSION_DENIED")
return self._account_view(session, session.get(Account, principal.account_id)) return self._account_view(session, session.get(Account, principal.account_id),
principal=principal, include_hierarchy=True)
def query_accounts(self, secret, account_ids=None, page=1, size=20): def query_accounts(self, secret, account_ids=None, page=1, size=20):
with self.factory() as session: with self.factory() as session:
...@@ -333,7 +750,11 @@ class AccountService: ...@@ -333,7 +750,11 @@ class AccountService:
validate_request_id(request_id) validate_request_id(request_id)
platform_operations = {"GRANT_CLIENT", "REVOKE_CLIENT", "SET_ACCOUNT_STATUS", "RECONCILE"} platform_operations = {"GRANT_CLIENT", "REVOKE_CLIENT", "SET_ACCOUNT_STATUS", "RECONCILE"}
credential_operations = {"ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL", "REVOKE_CREDENTIAL"} credential_operations = {"ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL", "REVOKE_CREDENTIAL"}
if operation not in platform_operations | credential_operations | {"PROVISION_ACCOUNT"}: sub_account_operations = {"CREATE_SUB_ACCOUNT", "SET_SUB_ACCOUNT_STATUS", "TRANSFER"}
scope_operations = {"BIND_SCOPE", "SET_SCOPE_STATUS", "MOVE_STORE", "MOVE_EMPLOYEE",
"SET_SCOPE_QUOTA"}
if operation not in (platform_operations | credential_operations | sub_account_operations
| scope_operations | {"PROVISION_ACCOUNT"}):
raise AccountError("INVALID_ARGUMENT") raise AccountError("INVALID_ARGUMENT")
identity = self.identity(secret) identity = self.identity(secret)
require_management(identity) require_management(identity)
...@@ -354,6 +775,9 @@ class AccountService: ...@@ -354,6 +775,9 @@ class AccountService:
if target.category != "CALL": if target.category != "CALL":
raise AccountError("PERMISSION_DENIED") raise AccountError("PERMISSION_DENIED")
account_id = target.account_id account_id = target.account_id
elif row.operation_type in sub_account_operations:
sub = session.get(SubAccount, row.target_id)
account_id = sub.account_id if sub is not None else None
else: else:
account_id = row.target_id if row.operation_type != "RECONCILE" else None account_id = row.target_id if row.operation_type != "RECONCILE" else None
if account_id is not None: if account_id is not None:
...@@ -369,6 +793,21 @@ class AccountService: ...@@ -369,6 +793,21 @@ class AccountService:
digest = fingerprint({"name": name, "clientId": client_id}) digest = fingerprint({"name": name, "clientId": client_id})
for attempt in range(2): for attempt in range(2):
try: try:
# 号段在业务锁之前领取:先只读确认,命中的重放直接回执(不依赖发号器)。
with self.factory() as probe:
principal = authenticate(probe, secret)
require_management(principal)
owner = _client_scope(principal, client_id)
client = probe.get(Client, owner)
if client is None or client.status != "ACTIVE":
raise AccountError("PERMISSION_DENIED")
replayed = self._request(probe, principal, "PROVISION_ACCOUNT", request_id)
if replayed is not None:
_account_scope(probe, principal, probe.get(Account, replayed.target_id))
if replayed.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH")
return self._view(probe, replayed)
ids = self._ids(3)
with self.factory.begin() as session: with self.factory.begin() as session:
principal = authenticate(session, secret) principal = authenticate(session, secret)
require_management(principal) require_management(principal)
...@@ -382,7 +821,6 @@ class AccountService: ...@@ -382,7 +821,6 @@ class AccountService:
if row.fingerprint != digest: if row.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH") raise AccountError("FINGERPRINT_MISMATCH")
return self._view(session, row) return self._view(session, row)
ids = self._ids(3)
account = Account(id=ids[1], name=name, remark=remark, owner_client_id=owner, account = Account(id=ids[1], name=name, remark=remark, owner_client_id=owner,
gateway_token_name="computing-%s" % ids[1], gateway_token_name="computing-%s" % ids[1],
gateway_snapshot={"gatewayId": self.settings.gateway_identity, gateway_snapshot={"gatewayId": self.settings.gateway_identity,
......
...@@ -46,6 +46,29 @@ def _clear_lease(call): ...@@ -46,6 +46,29 @@ def _clear_lease(call):
call.version += 1 call.version += 1
def _employee_scope(call):
"""Only an employee call keeps monthly usage. The registration snapshot
separates the subjects: a store call snapshots scope_id == store_scope_id,
an employee call snapshots its own scope plus the store it belonged to."""
if call.scope_id is None or call.store_scope_id is None or call.scope_id == call.store_scope_id:
return None
return call.scope_id
def _warn_overage(session, call, scope_id, units):
"""S31:软限额只保证「准入时未超」,并发的在途结算仍可能把当月用量推过限额。
只在**跨越那一刻**记一条结构化告警(准入端已拦后续调用),不逐笔刷屏。"""
usage = repository.find_month_usage(session, scope_id, call.quota_month)
row = repository.find_scope_quota(session, scope_id, call.store_scope_id)
limit = row.monthly_quota_point_units if row is not None else None
if limit is None or usage <= limit or usage - units >= limit:
return
logger.warning("scope month quota exceeded, accountId=%s scopeId=%s storeScopeId=%s quotaMonth=%s "
"limitPointUnits=%s usedPointUnits=%s overPointUnits=%s callId=%s",
call.account_id, scope_id, call.store_scope_id, call.quota_month,
limit, usage, usage - limit, call.id)
def apply_settlement(session, account, call, evidence, consumption_id, point_id, def apply_settlement(session, account, call, evidence, consumption_id, point_id,
*, source="GATEWAY_LOG", now): *, source="GATEWAY_LOG", now):
binding = _binding(call) binding = _binding(call)
...@@ -71,7 +94,18 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id, ...@@ -71,7 +94,18 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id,
raise AccountError("ACCOUNT_BLOCKED", reason="INVALID_SETTLEMENT_EVIDENCE") raise AccountError("ACCOUNT_BLOCKED", reason="INVALID_SETTLEMENT_EVIDENCE")
try: try:
units = require_bigint(quota * POINT_UNITS_PER_QUOTA) units = require_bigint(quota * POINT_UNITS_PER_QUOTA)
before = require_bigint(account.balance_point_units) except (ValueError, OverflowError):
raise AccountError("ACCOUNT_BLOCKED", reason="SETTLEMENT_AMOUNT_INVALID") from None
# The registration snapshot decides the charging bucket; later reassignment
# of scopes or sub-accounts never moves an in-flight call.
bucket = None
if call.sub_account_id is not None:
bucket = repository.lock_sub_account(session, call.sub_account_id)
if bucket is None or bucket.account_id != call.account_id:
raise AccountError("ACCOUNT_BLOCKED", reason="INVALID_SETTLEMENT_EVIDENCE")
try:
holder = bucket if bucket is not None else account
before = require_bigint(holder.balance_point_units)
after = require_bigint(before - units) after = require_bigint(before - units)
except (ValueError, OverflowError): except (ValueError, OverflowError):
raise AccountError("ACCOUNT_BLOCKED", reason="SETTLEMENT_AMOUNT_INVALID") from None raise AccountError("ACCOUNT_BLOCKED", reason="SETTLEMENT_AMOUNT_INVALID") from None
...@@ -84,6 +118,7 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id, ...@@ -84,6 +118,7 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id,
output_tokens=call.output_tokens, total_tokens=call.total_tokens, output_tokens=call.output_tokens, total_tokens=call.total_tokens,
consumed_quota=quota, point_units_per_quota=POINT_UNITS_PER_QUOTA, consumed_point_units=units, consumed_quota=quota, point_units_per_quota=POINT_UNITS_PER_QUOTA, consumed_point_units=units,
settlement_status="SUCCESS", settlement_source=source, settled_time=now, settlement_status="SUCCESS", settlement_source=source, settled_time=now,
sub_account_id=call.sub_account_id, scope_id=call.scope_id, store_scope_id=call.store_scope_id,
) )
session.add(consumption) session.add(consumption)
session.flush() session.flush()
...@@ -93,9 +128,18 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id, ...@@ -93,9 +128,18 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id,
point_units=-units, balance_before_units=before, balance_after_units=after, point_units=-units, balance_before_units=before, balance_after_units=after,
point_units_per_quota=POINT_UNITS_PER_QUOTA, call_id=call.id, point_units_per_quota=POINT_UNITS_PER_QUOTA, call_id=call.id,
consumption_record_id=consumption_id, request_id=call.request_id, consumption_record_id=consumption_id, request_id=call.request_id,
sub_account_id=call.sub_account_id,
)) ))
account.balance_point_units = after holder.balance_point_units = after
account.version += 1 holder.version += 1
month_scope = _employee_scope(call)
if month_scope is not None and call.quota_month:
# Monthly usage is keyed by (employee, month): a new month starts from
# zero with no reset job, and the employee carries what it consumed into
# whichever store it moved to. An in-flight call always lands in the
# month it was registered in, never in the month it settles in.
repository.upsert_month_usage(session, month_scope, call.quota_month, units, now=now)
_warn_overage(session, call, month_scope, units)
call.gateway_request_id = request_id call.gateway_request_id = request_id
call.consumed_quota = quota call.consumed_quota = quota
call.point_units_per_quota = POINT_UNITS_PER_QUOTA call.point_units_per_quota = POINT_UNITS_PER_QUOTA
...@@ -215,10 +259,20 @@ class SettlementService: ...@@ -215,10 +259,20 @@ class SettlementService:
call.error_code, call.error_message = code, None call.error_code, call.error_message = code, None
if permanent or call.retry_count >= 24: if permanent or call.retry_count >= 24:
call.billing_status, call.complete_time = "SETTLE_FAILED", now call.billing_status, call.complete_time = "SETTLE_FAILED", now
logger.warning("settlement stopped, accountId=%s callId=%s code=%s retryCount=%s "
"status=SETTLE_FAILED scopeId=%s storeScopeId=%s quotaMonth=%s",
call.account_id, call.id, code, call.retry_count, call.scope_id,
call.store_scope_id, call.quota_month)
else: else:
call.billing_status = "SETTLE_PENDING" call.billing_status = "SETTLE_PENDING"
minutes = _RETRY_MINUTES[min(max(call.retry_count - 1, 0), len(_RETRY_MINUTES) - 1)] minutes = _RETRY_MINUTES[min(max(call.retry_count - 1, 0), len(_RETRY_MINUTES) - 1)]
call.next_retry_time = now + timedelta(minutes=minutes) call.next_retry_time = now + timedelta(minutes=minutes)
# S30:这段窗口里调用已放行、用量未落账,软限额会短暂放宽;把重试节奏
# 记成结构化字段,运维才能对「结算延迟窗口」设阈值。
logger.warning("settlement retry scheduled, accountId=%s callId=%s code=%s retryCount=%s "
"nextRetryMinutes=%s scopeId=%s storeScopeId=%s quotaMonth=%s",
call.account_id, call.id, code, call.retry_count, minutes, call.scope_id,
call.store_scope_id, call.quota_month)
return False return False
def _retain_negative(self, state, evidence): def _retain_negative(self, state, evidence):
......
...@@ -11,7 +11,7 @@ from .account_model_gateway import ModelGateway ...@@ -11,7 +11,7 @@ from .account_model_gateway import ModelGateway
from .account_models import IdSegment from .account_models import IdSegment
from .account_service import AccountService from .account_service import AccountService
from .account_settlement import SettlementService from .account_settlement import SettlementService
from .db import create_mysql_engine from .db import create_generator_engine, create_mysql_engine
from .id_generator import SegmentIDGenerator from .id_generator import SegmentIDGenerator
...@@ -22,12 +22,17 @@ async def run(settings, batch_size): ...@@ -22,12 +22,17 @@ async def run(settings, batch_size):
settings.validate() settings.validate()
engine = create_mysql_engine(settings.database_url, pool_size=settings.db_pool_size, engine = create_mysql_engine(settings.database_url, pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow, bounded_operations=True) max_overflow=settings.db_max_overflow, bounded_operations=True)
# 发号器独立小池:结算等写事务不得为号段分配去抢业务连接池。
generator_engine = create_generator_engine(
settings.database_url, pool_size=settings.db_generator_pool_size,
max_overflow=settings.db_generator_max_overflow)
gateway = None gateway = None
try: try:
await asyncio.to_thread(_database_ready, engine) await asyncio.to_thread(_database_ready, engine)
factory = sessionmaker(engine, expire_on_commit=False) factory = sessionmaker(engine, expire_on_commit=False)
generator_factory = sessionmaker(generator_engine, expire_on_commit=False)
gateway = ModelGateway(settings) gateway = ModelGateway(settings)
accounts = AccountService(factory, SegmentIDGenerator(factory, segment_model=IdSegment), gateway, settings) accounts = AccountService(factory, SegmentIDGenerator(generator_factory, segment_model=IdSegment), gateway, settings)
settlements = SettlementService(accounts) settlements = SettlementService(accounts)
settled = await settlements.run_once(batch_size) settled = await settlements.run_once(batch_size)
cleared = await run_database(accounts, settlements.clear_results, batch_size) cleared = await run_database(accounts, settlements.clear_results, batch_size)
...@@ -37,6 +42,7 @@ async def run(settings, batch_size): ...@@ -37,6 +42,7 @@ async def run(settings, batch_size):
if gateway is not None: if gateway is not None:
await gateway.aclose() await gateway.aclose()
engine.dispose() engine.dispose()
generator_engine.dispose()
def main(): def main():
......
...@@ -55,6 +55,17 @@ def create_mysql_engine(database_url: str, *, pool_size=5, max_overflow=0, bound ...@@ -55,6 +55,17 @@ def create_mysql_engine(database_url: str, *, pool_size=5, max_overflow=0, bound
return engine return engine
def create_generator_engine(database_url: str, *, pool_size=2, max_overflow=2):
"""发号器专用连接池:号段分配不得占用业务连接池。
写请求在业务事务里申请新号段时,若与业务共用连接池,池内无空闲连接就会等到
`pool_timeout` 再抛 503(`db_pool_size=1`、或池被在途事务占满时必现)。号段一次
取 1000 个、单次分配只是"锁一行 + 写一行 + 提交",独立小池足够,也不会挤压业务池。
"""
return create_mysql_engine(database_url, pool_size=pool_size, max_overflow=max_overflow,
bounded_operations=True)
def init_engine(settings: Settings) -> None: def init_engine(settings: Settings) -> None:
"""按环境配置初始化全局 Engine。重复调用忽略(uvicorn 多次加载保护)。""" """按环境配置初始化全局 Engine。重复调用忽略(uvicorn 多次加载保护)。"""
global _engine, _session_factory global _engine, _session_factory
......
# 账户层级、子账号与门店/员工软限额设计(草案) # 账户层级、子账号与门店/员工限额设计(v0.2)
- 状态:**草案 DRAFT v0.1**,待用户确认 §9 的语义点后并入 P0 契约,不表示已实现 - 状态:**草案 DRAFT v0.2**,全部核心语义已由用户 2026-09-29 确认(§2.1、§12,含 D8);同日两轮外部审计复核修订(复核一/复核二,明细见 §13 修订记录);待并入 P0 契约;不表示已实现
- 上位文档:`docs/p0-contract-freeze.md`(契约)、`docs/independent-account-development-plan.md`(边界与迁移) - 上位文档:`docs/p0-contract-freeze.md`(契约)、`docs/independent-account-development-plan.md`(边界与迁移)
- 网关实测资料:`shared/newapi/`(New API `v1.0.0-rc.37` @ `aigateway.mei1.info`,凡"实测"均出自该目录) - 网关实测资料:`shared/newapi/`(New API `v1.0.0-rc.37` @ `aigateway.mei1.info`,凡"实测"均出自该目录)
- 本草案新增的需求来源:商户总账号 → 子账号 → 门店 → 员工四级归属;门店/员工"最高使用金额 + 可追加";调用粒度 = 员工级/门店级 - 需求来源:商户总账号 → 子账号 → 门店 → 员工四级归属;总账号把余额分配给子账号;门店划归唯一账号;员工按自然月限额;调用粒度 = 员工级/门店级
- **相对 v0.1 的变更**(v0.1 的 §3/§5/§6 相应作废):
1. 门店**不再限额**(v0.1 的门店档 limit/extra/used 删除)——门店消耗只受所属子账号余额约束;
2. 员工限额从「累计 limit + 追加 extra」改为「**自然月额度,每月按届时当前配置重置**」;"额外增加金额" = 调整当月额度值,不再是独立累加器;
3. 主体识别从「请求参数 `usageScope` + 锚点校验」改为「**门店/员工级独立 CALL Key**」(用户明确选择,推翻 v0.1 §3 的否决);`usageScope` 请求字段取消,指纹改动保留但**收窄为仅加凭证静态主体**(§3.5/§7.1),OpenAI 兼容层协议零改动;
4. 新增:换门店后月内已用量**跨店累计**(不清零,防换店绕限额);员工未配置额度 = **不限**(仅受余额约束);月界按 **Asia/Shanghai 自然月**;
5. 补齐 v0.1 缺口:子账号账本落点(point_record 分桶 + TRANSFER 配对规则,§5)、跨月结算的月份快照(§7)。
--- ---
...@@ -15,17 +21,45 @@ ...@@ -15,17 +21,45 @@
| `/api/token/*` 全部 UserAuth,**无管理员代管令牌接口**;PAT 无法程序化生成/续期 | 用"每子账号一个 NewAPI 账号"= 每开一个子账号要人工交付一个 PAT,运营不可行 | | `/api/token/*` 全部 UserAuth,**无管理员代管令牌接口**;PAT 无法程序化生成/续期 | 用"每子账号一个 NewAPI 账号"= 每开一个子账号要人工交付一个 PAT,运营不可行 |
| 每用户令牌上限默认 1000;本服务按名全量分页上限 5000 | 门店/员工**不能**映射成令牌 | | 每用户令牌上限默认 1000;本服务按名全量分页上限 5000 | 门店/员工**不能**映射成令牌 |
| `PUT /api/token/` 全量覆盖、无乐观锁;`expired_time` 被清成 0 会静默 401 | **余额分配不能实现为改令牌额度** | | `PUT /api/token/` 全量覆盖、无乐观锁;`expired_time` 被清成 0 会静默 401 | **余额分配不能实现为改令牌额度** |
| 计费权威 = 使用日志 `quota`(响应体无费用字段);日志维度只有 token/user | 网关只能出「商户总账号」账;门店/员工账必须本地出 | | 计费权威 = 使用日志 `quota`(响应体无费用字段);日志维度只有 token/user | 网关只能出「商户总账号」账;子账号/门店/员工账必须本地出 |
**结论**:NewAPI 只承担「商户总账号的额度池 + 权威消费账本」;层级、归属与限额全部落在 mei1_computing。 **结论**:NewAPI 只承担「商户总账号的额度池 + 权威消费账本」;层级、归属与限额全部落在 mei1_computing。
**金额口径**:`quota_per_unit=500000`、`usd_exchange_rate=7.3` ⇒ `¥1 = 68493 quota`;结合现有 `POINT_UNITS_PER_QUOTA=146`、`POINT_SCALE=10000` ⇒ **1 积分 = ¥0.001,1000 积分 = ¥1**。"最高使用金额 ¥100" = `100000 积分` = `14600000 子单位` = `6849320 quota`。 **金额口径**:`quota_per_unit=500000`、`usd_exchange_rate=7.3` ⇒ `¥1 ≈ 68493 quota`;结合现有 `POINT_UNITS_PER_QUOTA=146`、`POINT_SCALE=10000` ⇒ **1 积分 = ¥0.001,1000 积分 = ¥1**。权威换算链:`子单位 = 积分 × 10000 = quota × 146`(结算按 `quota × 146` 入账)。"员工月额度 ¥100" = `100000 积分` = `1,000,000,000 子单位`(≈ `6849315 quota`,quota 侧恒为整数取整,仅供与网关对照)。
--- ---
## 2. 数据模型(新增 2 张表 + 3 处扩列,沿用 `t_computing_*` / 18 位 ID / UTC DATETIME(3) 约定) ## 2. 总体模型与已确认语义
### 2.1 新表 `t_computing_sub_account` ```
账户(MASTER,网关绑定不变)
├─ 未分配余额(账户桶,现有行为)
│ └─ 直挂门店/员工(sub_account_id=NULL:无子账号时共用总账号余额,D10)
└─ 子账号(分配即冻结,独立余额桶)
└─ 门店(归属唯一;无限额;报表维度)
└─ 员工(唯一限额层:自然月额度,月度重置)
```
### 2.1 已冻结决策(2026-09-29 用户确认)
| # | 决策 | 语义 |
|---|---|---|
| C1 | **门店无限额** | 门店消耗上限 = 所属子账号余额(直挂账户时 = 总账号未分配余额,D10);门店仅承担归属与账单归属维度,不建任何限额/用量状态。需求原文的「门店级预算」= **约定一店一子账号**(子账号余额即该店预算,复用分配即冻结 + 准入读余额);模型**不强制**唯一——N 个门店可共享 1 个子账号做分组,共享时预算按组共用、不提供门店级独立预算。是否一店一子账号由商户划拨时自行决定;约束级强制做法见 §3.2(生成列 + 唯一),本期不做 |
| C2 | **员工月度限额** | 按自然月(Asia/Shanghai)重置;每月额度 = 重置时刻员工的**当前配置值**;月中可随时调整,下一次调用准入即按新值判断 |
| C3 | **换店不清零** | 员工月内已用量跟随**员工主体**跨店累计;换店只切换"按哪个门店的额度配置执行、扣哪个子账号的余额",用量不清零(防换店绕限额) |
| C4 | **未配置 = 不限** | 员工无生效月度额度配置时不限额(从未配置过任何门店,或当前店配置显式为「不限」),仅受账户/子账号余额约束 |
| C5 | **Key 识别主体** | 调用主体由 CALL Key 决定:门店 Key / 员工 Key / 子账号 Key / 账户级 Key 四类;不引入 `usageScope` 请求参数 |
### 2.2 与现有语义的同构性
- 限额与余额同构:**准入只读比对 + 结算事务实际入账**。准入检查 `当月已用 < 当前额度`、`余额 > 0`;单次在途调用可能小幅越过限额(与"余额>0 才准入、结算时实际扣"同一条语义线),超额窗口 = **从派发到结算落账的全部时间**(在途执行预算 ≤ 150s + 补偿退避 1/5/15/60 分钟、**第 4 次起封顶 60 分钟**、24 次尝试 ≈ **21 小时上限**),不止 SETTLE_PENDING 阶段;靠指标告警兜底,不做预留/预扣(P0 §3.2 不变)。
- 硬阻断不变:任一在途调用 UNKNOWN/未核清 → 阻断**账户及其全部子账号**的新调用(C 端冒进风险在账户层闭合)。
---
## 3. 数据模型(4 张新表 + 6 处扩列,沿用 `t_computing_*` / 18 位 ID / UTC DATETIME(3) 约定)
### 3.1 新表 `t_computing_sub_account`
| 列 | 类型 | 说明 | | 列 | 类型 | 说明 |
|---|---|---| |---|---|---|
...@@ -35,230 +69,363 @@ ...@@ -35,230 +69,363 @@
| `remark` | VARCHAR(500) NULL | 400/500 字符上限沿用现有约定 | | `remark` | VARCHAR(500) NULL | 400/500 字符上限沿用现有约定 |
| `status` | ENUM('ACTIVE','DISABLED') | DISABLED 拒绝其下全部新调用 | | `status` | ENUM('ACTIVE','DISABLED') | DISABLED 拒绝其下全部新调用 |
| `balance_point_units` | BIGINT NOT NULL | 有符号子单位;分配即冻结 | | `balance_point_units` | BIGINT NOT NULL | 有符号子单位;分配即冻结 |
| `transfer_gate` | BIGINT UNSIGNED NULL | 转账门闩(复用 account.gift_gate 的思路),占用中的 transferId |
| `version` | INT NOT NULL | 条件写围栏 | | `version` | INT NOT NULL | 条件写围栏 |
| 索引 | `(account_id, create_time, id)`;`UNIQUE(account_id, name)` | | | 索引 | `(account_id, create_time, id)`;`UNIQUE(account_id, name)` | |
**不绑网关令牌**(理由见 §1)。 **不绑网关令牌**(理由见 §1)。
### 2.2 新表 `t_computing_scope`(门店/员工限额主体) ### 3.2 新表 `t_computing_scope`(门店/员工主体)
| 列 | 类型 | 说明 | | 列 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `id` | BIGINT UNSIGNED PK | 18 位,即 `scopeId` | | `id` | BIGINT UNSIGNED PK | 18 位,即 `scopeId` |
| `account_id` | BIGINT UNSIGNED NOT NULL | 归属账户(冗余,用于复合外键与查询) | | `account_id` | BIGINT UNSIGNED NOT NULL | 归属商户账户(冗余,供复合外键与授权校验) |
| `sub_account_id` | BIGINT UNSIGNED NOT NULL FK sub_account | 门店挂载的子账号(员工继承其门店) | | `sub_account_id` | BIGINT UNSIGNED NULL FK sub_account | **仅门店行可非空**(CHECK:EMPLOYEE 必须为 NULL);NULL = **直挂账户**(无子账号时门店/员工共用总账号未分配余额,D10),非空 = 挂子账号;员工所属子账号一律经 parent 门店解析,不存冗余列(消除改派漂移面) |
| `scope_type` | ENUM('STORE','EMPLOYEE') | | | `scope_type` | ENUM('STORE','EMPLOYEE') | |
| `scope_key` | VARCHAR(100) ascii_bin | **SaaS 侧 opaque 标识**(如 `store:123`),本服务不解析、不查 SaaS | | `scope_key` | VARCHAR(100) ascii_bin | **来源系统的 opaque 标识,必须带来源命名空间前缀**(如 `saas:store:123` / `saas:emp:456`),本服务不解析、不查 SaaS;前缀防多来源系统撞名。格式校验:`^[a-z0-9-]{1,30}:[A-Za-z0-9._:-]{1,69}$`(总长 ≤ 100;来源前缀仅小写字母/数字/连字符,主体不含空白,拒绝 `a:`、`:x:` 之类退化形态) |
| `parent_scope_id` | BIGINT UNSIGNED NULL FK scope | 员工→门店;门店为 NULL | | `parent_scope_id` | BIGINT UNSIGNED NULL FK scope | 员工→门店;门店为 NULL。**员工换店 = 变更此列**(管理操作,幂等 + 审计 + version 围栏) |
| `limit_point_units` | BIGINT NOT NULL DEFAULT 0 | **最高使用金额** |
| `extra_point_units` | BIGINT NOT NULL DEFAULT 0 | **额外增加金额**(追加,只增不减,可审计) |
| `used_point_units` | BIGINT NOT NULL DEFAULT 0 | 累计消耗(**软限额:由结算事务累加**) |
| `status` | ENUM('ACTIVE','DISABLED') | 停用拒新调用,不影响在途结算 | | `status` | ENUM('ACTIVE','DISABLED') | 停用拒新调用,不影响在途结算 |
| `version` | INT NOT NULL | | | `version` | INT NOT NULL | |
| 唯一/索引 | `UNIQUE(account_id, scope_type, scope_key)`(**一个门店只能存在一个账号下**);`UNIQUE(parent_scope_id, scope_type, scope_key)` 不适用,改为业务校验;`(sub_account_id, scope_type, status)`;`(parent_scope_id)` | | | 唯一/索引 | `UNIQUE(scope_type, scope_key)` 全局唯一(**一店一账号**跨账户硬保证;配合命名空间前缀避免多来源撞名);`(sub_account_id, scope_type, status)`(门店列表);`(parent_scope_id)` | |
**跨商户迁移不支持**:scope 与账户终身绑定;确需迁移由来源系统换新 `scope_key` 重建、旧 scope 停用,历史消费留旧账(账本不可改写)。
可用额度 = `limit_point_units + extra_point_units − used_point_units`。 **跨行约束走应用层**(MySQL CHECK 不能跨行/跨表):EMPLOYEE 行 `parent_scope_id` 必非空且指向**同账户**的 ACTIVE STORE、STORE 行 `parent_scope_id` 必为 NULL——由 BIND_SCOPE / MOVE_EMPLOYEE 同事务强校验 + §8 对账兜底;DDL 只保列可空与 FK。门店行 `sub_account_id` 必须属于**同账户**(BIND_SCOPE / MOVE_STORE 同事务校验)。`scope_type` **不可变**:无修改接口,变更主体类型走新建——防止员工 Key 因行被改成 STORE 而退化为无限额门店 Key、绕过月额度。
`used` **允许超过** `limit+extra`(软限额只拦新调用,不清账、不写成 0)。
### 2.3 扩列 **一子账号可挂多个门店**(分组共用余额);「一店一子账号」的门店级预算口径见 C1,是**划拨约定而非约束**。如需收紧为「一子账号仅挂一门店」(约束级;MySQL 无部分索引):加生成列 `store_bucket BIGINT AS (IF(scope_type='STORE', sub_account_id, NULL)) STORED` + `UNIQUE(store_bucket)`(NULL 不参与唯一),本期不做。
**scope_key 前缀的落地方式**:服务层只做格式校验(必须含 `来源:` 前缀,写入 P0 §7.2 新接口要点);来源与前缀的绑定是接入约定,不做注册表。**同一业务实体必须固定由同一来源系统上报**,否则会建成两个 scope 主体、分账裂开(对账项兜底检查同前缀分布)。
**不设 limit/extra/used 列**:门店无限额(C1);员工额度见 3.3;员工月用量见 3.4。
### 3.3 新表 `t_computing_scope_quota`(员工×门店月度额度配置)
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | BIGINT UNSIGNED PK | 18 位 |
| `scope_id` | BIGINT UNSIGNED NOT NULL FK scope(EMPLOYEE) | 员工主体 |
| `store_scope_id` | BIGINT UNSIGNED NOT NULL FK scope(STORE) | 配置所属门店 |
| `monthly_quota_point_units` | BIGINT NULL | 行存在即「已配置」:**NULL = 显式不限(终态,不再回退)**,`0` = 零额度(拒绝全部调用),`>0` = 月度上限;CHECK `>= 0`;调整 = 覆盖此值(管理操作,幂等 + 审计前后值) |
| `version` | INT NOT NULL | |
| 唯一 | `UNIQUE(scope_id, store_scope_id)` | |
**生效额度解析**(2026-09-30 决策:**无回退链**):
`当前 (员工, 所属门店) 的配置行 → 该门店无行 = 不限(不取该员工其他门店的配置值)`。
- 「行不存在」= 该门店未配置 = 不限;「行存在且值为 NULL」= 显式不限——两条路径都落到「不限」,实现上是同一个判断(额度值为 NULL 即跳过比对)。
- 口径依据:员工使用上限**取自门店**、换店后**按新门店执行**(用户 2026-09-30 决策)。跨店保留的是**已用量**(§3.4 跨店累计、不清零),不是配置值。
- 与 2026-09-29 v0.2 的差别:原「无行时取该员工最近一次设置值」的回退链**作废**——不复用旧门店额度、不让停用门店的配置行参与解析,`last_update_time` 排序口径随之取消。
- **已明示的残余风险**:员工调到「未配置额度」的门店即不受月度限额约束(换店绕限额的口子)。若后续要观测,建议在段 3 的超额/风险告警里加「员工在无额度门店调用」的结构化 warning(本期不做)。
- 历史月额度不做快照表:复算某时刻生效额度 = 按 `operation_audit` 时间序重放 `SET_SCOPE_QUOTA`(含行前后值)与 `MOVE_EMPLOYEE`(含旧/新 parent)。无回退链后不再依赖行 `last_update_time` 排序,但审计仍记录该值供排障。
### 3.4 新表 `t_computing_scope_month_usage`(员工月用量)
| 列 | 类型 | 说明 |
|---|---|---|
| `scope_id` | BIGINT UNSIGNED NOT NULL FK scope(EMPLOYEE) | 用量跟随员工主体(C3 跨店累计) |
| `month` | CHAR(7) NOT NULL | `'YYYY-MM'`,按 **Asia/Shanghai** 自然月;值 = 调用**登记时间**所在月(快照进 call 行,见 §7)。时间戳列一律 UTC,`month` 是唯一按时区派生的业务键(S44) |
| `used_point_units` | BIGINT NOT NULL DEFAULT 0 | 结算事务原子累加;允许超过当月额度(软限额语义) |
| 唯一 | `UNIQUE(scope_id, month)` | **月份是状态键 ⇒ 重置是数据结构性质**:新月首条 INSERT 从 0 开始,无定时重置任务、无漏跑风险 |
无 `version` 列:累加用原子 UPSERT(§7.2),行级正确性由唯一键 + call 结算唯一性兜底。
### 3.5 扩列
| 表 | 新增 | 说明 | | 表 | 新增 | 说明 |
|---|---|---| |---|---|---|
| `t_computing_credential` | `sub_account_id BIGINT UNSIGNED NULL` | CALL 类可选填;非空则为"子账号凭证"。需与 `(account_id, client_id)` 一致性约束(复合 FK 或业务校验 + CHECK) | | `t_computing_credential` | `sub_account_id BIGINT UNSIGNED NULL`、`scope_id BIGINT UNSIGNED NULL` | CALL 类四选一组合(CHECK 强制互斥,见 §4);scope_id 指向 STORE 或 EMPLOYEE scope;**仅 CALL 类可带** scope_id/sub_account_id,且两者必须属于同一 account_id(跨表校验在应用层) |
| `t_computing_call` | `sub_account_id`(快照)、`usage_scope_type`、`usage_scope_key`、`parent_scope_key`(快照) | 登记时固化归属,用于结算时的双档扣减;**进指纹** | | `t_computing_point_record` | `sub_account_id BIGINT UNSIGNED NULL`、`type` 增 `TRANSFER_OUT`/`TRANSFER_IN` | **账本分桶**:`sub_account_id IS NULL` = 账户桶,非空 = 子账号桶(§5 不变式)。⚠️ 需修订 P0 §2.8(现仅 GIFT/CONSUME) |
| `t_computing_consumption` | `sub_account_id`、`usage_scope_type`、`usage_scope_key` | 门店/员工分账的数据源 | | `t_computing_call` | `sub_account_id`、`scope_id`、`store_scope_id`、`quota_month CHAR(7)`(登记快照) | 结算只认登记快照;调岗/改配/改派不改变在途调用归属。**进指纹的只有凭证静态主体**(credential 的 `scope_id`/`sub_account_id`,同一 client 不同主体 Key 互不重放);解析出的动态快照(当前门店、解析子账号、月份)**不进指纹**,只落 call 行——否则换店/改派/跨月后合法重放会被误判 FINGERPRINT_MISMATCH,违反 P0 §5「重放优先、指纹不含可变主数据」 |
| `t_computing_management_request` | `operation_type` 增 `CREATE_SUB_ACCOUNT`、`TRANSFER`、`BIND_SCOPE`、`SET_SCOPE_LIMIT`、`TOPUP_SCOPE` | 各自幂等域 | | `t_computing_consumption` | `sub_account_id`、`scope_id`、`store_scope_id` | 子账号/门店/员工分账的数据源 |
| `t_computing_point_record` | `type` 增 `TRANSFER_OUT`/`TRANSFER_IN` | ⚠️ 需修订 P0 §2.8(现只允许 GIFT/CONSUME,且明确"不提供任意余额 ADJUST");转账必须成对、同事务、守恒、可审计 | | `t_computing_management_request` | `operation_type` 增 `CREATE_SUB_ACCOUNT`、`TRANSFER`、`BIND_SCOPE`、`MOVE_EMPLOYEE`、`MOVE_STORE`、`SET_SCOPE_QUOTA`、`SET_SCOPE_STATUS`、`SET_SUB_ACCOUNT_STATUS`(8 个) | 各自幂等域;沿用 fingerprint + request_id 框架;停用/启用与建档**拆分**(H1,先例 SET_ACCOUNT_STATUS) |
| `t_computing_operation_audit` | 复用,`target_type` 增 `SUB_ACCOUNT`/`SCOPE`;`evidence_ref` 记录 limit/extra 前后值 | | | `t_computing_operation_audit` | 复用;`target_type` 增 `SUB_ACCOUNT`/`SCOPE`/`SCOPE_QUOTA` | `evidence_ref` 记录额度/归属前后值 |
### 3.6 索引列序说明
复合唯一/查询索引按真实单列查询模式排首列:`scope_month_usage(scope_id, month)`(按员工查当月)、`scope(scope_type, scope_key)`(管理端按 key 查重/查询;调用期主体解析走 `credential.scope_id` → 主键,不走此索引)、`point_record(account_id, sub_account_id, type)`(分桶对账)。迁移脚本沿用 `render_account_ddl.py` 生成。
---
## 4. 凭证模型:四类 CALL Key(C5)
### 4.1 组合矩阵(CHECK 约束:`scope_id` 与 `sub_account_id` 互斥,不得同时非空)
| Key 类型 | credential | 扣减余额桶 | 限额 | call 快照(scope_id / store_scope_id / sub_account_id) |
|---|---|---|---|---|
| 账户级(现状) | `scope_id=NULL, sub_account_id=NULL` | 账户未分配余额 | 无 | NULL / NULL / NULL |
| 子账号级 | `sub_account_id=S` | 子账号 S 余额 | 无 | NULL / NULL / S |
| 门店级 | `scope_id=门店` | 所属子账号余额;门店直挂(sub_account_id=NULL)时 = 账户未分配余额(D10) | 无(C1) | 门店 / 门店(=scope_id) / 解析出的子账号(直挂时 NULL) |
| 员工级 | `scope_id=员工` | 员工当前门店所属子账号余额;门店直挂时 = 账户未分配余额(D10) | 员工月度额度(与挂载方式无关,始终生效) | 员工 / 当前门店 / 解析出的子账号(直挂时 NULL) |
快照取值直接决定分账聚合口径:门店账 = consumption 按 `store_scope_id` 聚合(门店 Key 与员工 Key 统一落入归属门店),员工账按 `scope_id`,子账号账按 `sub_account_id`(NULL 即账户桶——直挂门店/员工的消费与账户级 Key 消费同桶,但 scope 维度保留供分账报表);账户/子账号 Key 的 `store_scope_id` 为 NULL,不参与门店聚合(§8 恒等式依赖此定义)。
### 4.2 关键语义
- **归属由凭证决定,B 端无法伪造**:与现有"clientId 由凭证解析、不信任 Header/Body"完全同构;v0.1 的锚点校验随 `usageScope` 一并取消。
- **员工 Key 动态解析账户**:credential 不静态绑 sub_account;准入时按 `员工 → 当前门店 → 子账号` 解析并**快照进 call 行**。换店不换 Key(在途调用按快照结算)。
- **门店 Key 静态绑店**:Key 绑定的是门店而非子账号;门店改派后,旧 Key 在下一次准入时自动解析到新子账号桶并快照,**无需作废重发**——由 SaaS 通知 B 端刷新本地 Key 缓存即可。若安全上要求回收,走现有 revoke 流程(与改派同批执行),非强制。
- 撤销/轮换复用现有 credential 生命周期(PENDING 24h → activate → replaced_by 轮换 → revoke)。
- 运维成本已知并接受(v0.1 §3 的顾虑仍在):Key 数量 = 门店数 + 员工数;签发/轮换必须由 SaaS 侧程序化调用管理接口完成,本服务接口已支持幂等重放。
--- ---
## 3. 凭证模型:子账号用独立 CALL Key(建议采纳) ## 5. 账本分桶与转账(补 v0.1 缺口)
| 方案 | 归属可信度 | 成本 | 结论 | ### 5.1 分桶规则
`point_record` 每行归属唯一桶:`sub_account_id IS NULL` = **账户桶**;非空 = **子账号桶**。
| 流水类型 | 桶 | 方向 | 说明 |
|---|---|---|---| |---|---|---|---|
| 子账号**独立 CALL Key**(`credential.sub_account_id`) | 归属由凭证决定,B 端无法伪造 | 1 列 + `Principal` 加字段 + 签发/校验各 1 处 + 指纹加字段 | **采纳**:与现有"clientId 由凭证解析、不信任 Header/Body"完全同构;撤销/轮换能细到子账号 | | GIFT | 账户桶 | + | 现状不变(仍单一入口:先入总账号) |
| 只靠请求参数传 `subAccountId` | 依赖 B 端自律 | 0 | ❌ 越权面大,不采纳 | | CONSUME | 按扣费桶 | − | 账户级 Key 与**直挂门店/员工**的消费记账户桶(sub_account_id=NULL);挂子账号的门店/员工及子账号 Key 消费记对应子账号桶 |
| 门店/员工级独立 Key | — | 数量大(上千)+ 人员流动 → 生命周期不可运维 | ❌ 不采纳,用参数 + 锚点校验 | | TRANSFER_OUT | 转出桶 | − | 与同 transferId 的 TRANSFER_IN 同事务成对 |
| TRANSFER_IN | 转入桶 | + | |
**锚点校验(安全闭环)**:门店/员工的 `usageScope` 必须属于**该凭证绑定的子账号**(账户级凭证则必须属于该账户)。即使参数被伪造,也只能在自己子账号的门店集合里选,越不到别的子账号。 ### 5.2 不变式(对账脚本扩展点)
### 3.1 调用组合矩阵 1. `account.balance_point_units = Σ(账户桶流水)`(现状不变式收窄为仅账户桶);
2. `sub_account.balance_point_units = Σ(该子账号桶流水)`;
3. 转账守恒:每个 transferId 恰好一对 OUT/IN,金额相等、方向相反、同事务提交;无部分转账;
4. 总量守恒:`账户未分配 + Σ子账号余额` 只被 GIFT 改变,CONSUME/TRANSFER 净和为零。
| 凭证 | `usageScope` | 计费与扣减 | ### 5.3 转账操作(D1 分配即冻结,维持 v0.1 结论)
|---|---|---|
| 账户级 CALL Key(`sub_account_id=NULL`) | 省略 | 扣**账户未分配余额**(现存行为,向后兼容) |
| 账户级 CALL Key | `STORE:门店` | 校验门店∈账户 → 扣 门店.used + 门店所属子账号余额 |
| 账户级 CALL Key | `EMPLOYEE:员工` | 校验员工∈账户 → 扣 **员工.used + 员工门店.used** + 子账号余额 |
| 子账号 CALL Key | 省略 | 扣 子账号余额(不占门店/员工限额) |
| 子账号 CALL Key | `STORE/EMPLOYEE`(须属本子账号) | 双档扣减 + 扣本子账号余额 |
**另存于 call 行的快照**:登记时的 `sub_account_id / scope_type / scope_key / parent_scope_key`。之后调岗、改派、改限额都不改变在途调用的结算归属。 - `POST /internal/v1/accounts/{accountId}/sub-accounts/{subAccountId}/transfers {requestId, points, direction}`:单事务 = 转出桶余额条件扣减 + 转入桶余额加 + 成对流水 + 审计;余额不足整单拒绝。
- 方向:总→子(分配)、子→总(回流)。子账号互转本期不做(走回流+再分配,D2)。
- 结果不明:转账是**纯本地单事务**(无网关等外部写入),不存在 gift 那类外部不确定性,**不需要门闩**;按原 requestId 回查 `management_request` 即可判定已提交/未提交——已提交则重放返回原结果,未提交则重新执行;不换号重试。
- 金额单位:管理接口入参 `points`(积分,整数,与 gift 约定一致);存储列 `*_point_units`(子单位)= `points × 10000`;响应中 id/子单位/quota 一律十进制字符串、积分展示固定 4 位(P0 §5 精度条款)。
- 子账号余额不足的消费 → 沿用既有 `409 ACCOUNT_BLOCKED` + `reason=INSUFFICIENT_BALANCE`(**不新增错误码**),`data` 增加桶标识 `subAccountId`;OpenAI 兼容层既有映射照常输出 `429 insufficient_quota`。
--- ---
## 4. 完整流程:从开通到调用 ## 6. 管理操作清单(全部套现有幂等框架)
### 阶段 A 商户总账号开通(一次性,沿用现有 P2,不变) | 操作 | 接口形态 | 幂等域 | 要点 |
|---|---|---|---|
| 创建子账号 | `POST /internal/v1/accounts/{id}/sub-accounts` | `CREATE_SUB_ACCOUNT` | 余额 0;重放返回同一 subAccountId |
| 余额划拨 | `.../sub-accounts/{id}/transfers` | `TRANSFER` | §5.3 |
| 建/挂门店、员工 | `POST /internal/v1/accounts/{id}/scopes` | `BIND_SCOPE` | 仅**建档**(员工须带 parentScopeKey);一店一账号冲突 → `409 SCOPE_CONFLICT`;状态变更必须走 SET_SCOPE_STATUS(同号先建后停不得共用指纹,H1) |
| 门店/员工停用、启用 | `POST .../scopes/{id}/status` | `SET_SCOPE_STATUS` | 停用拒新调用(`SCOPE_DISABLED`),在途按快照照常结算;停用不改写配置行 |
| 子账号停用、启用 | `POST .../sub-accounts/{id}/status` | `SET_SUB_ACCOUNT_STATUS` | 同上拆分;停用拒新调用(`SUB_ACCOUNT_DISABLED`);余额不自动回流 |
| 门店改派子账号 | `POST .../scopes/{id}/sub-account` | `MOVE_STORE` | 门店行 `sub_account_id` 变更(**目标可 NULL**:挂子账号 ↔ 直挂账户双向,D10);旧门店 Key 自动解析新桶、免重发(§4.2);不移动任何余额(D4);历史消费不改写 |
| 员工换店 | `POST .../scopes/{id}/assignment` | `MOVE_EMPLOYEE` | 改 `parent_scope_id`;新店额度按 §3.3 解析;历史消费不改写;「换店不清零、跨月重置」的现场提示由 SaaS 侧产品文案承担 |
| 设员工月额度 | `POST .../scopes/{id}/quota {storeScopeId, monthlyQuotaPoints}` | `SET_SCOPE_QUOTA` | 立即生效;低于已用允许(拒新调用至次月或上调);审计前后值 |
| 签发四类 Key | 现有 credentials 接口扩展 `{subAccountId?, scopeId?}` | `ISSUE_CALL_KEY` 指纹**必须**扩为 `{accountId, clientId, subAccountId?, scopeId?}`(现状仅 `{accountId, clientId}`,否则同号给不同员工签发会被误判重放、串发 Key) | PENDING→activate;secret 仅首次返回;批量签发由 SaaS 侧持久化 requestId + 重放断点续传 |
> 账户级 CALL Key(`subAccountId`/`scopeId` 均空)**仅 PLATFORM 可签发**(2026-09-30 决策):越权自签返回 `403 PERMISSION_DENIED` / `reason=ACCOUNT_KEY_PLATFORM_ONLY`。商户路径 = 子账号/门店/员工 Key;无子账号时用门店直挂账户(D10)覆盖「一账号不分店」。存量账户级 Key 不回收。
1. SaaS 后端持久化 `provision_requestId` → 调 `POST /internal/v1/accounts {name, remark}`(INTEGRATION owner=自己,或 PLATFORM 带目标 clientId) 新增管理操作的幂等指纹写全(同号异参必须 409)。**扩展现有操作时字段名逐字沿用既有命名,不得重命名**——`ISSUE_CALL_KEY` 现为 camelCase `{accountId, clientId}`,gift 为 snake_case `{account_id, points, operator_note}`;下表新操作命名跟随所属接口族:
2. 服务在短事务登记账户 + `provision_phase=REGISTERED` → 提交 → 事务外创建**零额度**网关 Token → 绑定 `gateway_token_id` + `gateway_snapshot`(含 `quotaPerUnit` 校对)
3. 响应丢失:只按原 `requestId` 查(`GET /internal/v1/requests/PROVISION_ACCOUNT/{requestId}`),**不创建第二个账户**
4. SaaS 侧写入商户映射(`t_saas_ai_computing_service_binding`:merchant_id → account_id + 开户请求号)
5. **给账户注入额度**:现只有 PLATFORM 赠送 `POST /internal/v1/gifts`(含单写独占证据 + 核清路径)。⚠️ 如需要"充值/购买积分"入口,本草案不含,见 §9 D3
6. (可选)签发账户级 CALL Key:`POST /internal/v1/accounts/{accountId}/credentials` → PENDING → 激活(secret 仅首次返回)→ SaaS 加密保存
### 阶段 B 层级与结构配置(运营/商户自助) | 操作 | 指纹 |
|---|---|
| CREATE_SUB_ACCOUNT | `{accountId, name}` |
| TRANSFER | `{accountId, subAccountId, points, direction}` |
| BIND_SCOPE | `{accountId, scopeType, scopeKey, subAccountId?, parentScopeKey?}`(subAccountId 缺省 = 直挂账户,D10;省略与显式 NULL 等价) |
| MOVE_EMPLOYEE | `{scopeId, parentScopeId}` |
| MOVE_STORE | `{scopeId, subAccountId}` |
| SET_SCOPE_QUOTA | `{scopeId, storeScopeId, monthlyQuotaPoints}` |
| SET_SCOPE_STATUS | `{scopeId, status}` |
| SET_SUB_ACCOUNT_STATUS | `{subAccountId, status}` |
---
7. **创建子账号**:`POST /internal/v1/accounts/{accountId}/sub-accounts {requestId, name, remark}` → `subAccountId`,余额 0 ## 7. 调用与结算链路
8. **余额分配**:`POST /internal/v1/accounts/{accountId}/sub-accounts/{subAccountId}/transfers {requestId, points, direction: IN|OUT, operatorNote, evidenceRef}`
- 单事务:账户 `balance −points×10000`、子账号 `balance +points×10000`、成对 `TRANSFER_OUT/IN` 流水、审计;余额不足 → 拒绝,**不产生部分转账**
- 幂等:`management_request` 的 `TRANSFER` 域;未知结果只核对原请求,不换号重试
9. **建门店并挂子账号**:`POST .../scopes {requestId, scopeType:STORE, scopeKey:"store:123", subAccountId, limitPoints}`
- 门店已存在于本账户其它子账号 → `409 SCOPE_CONFLICT`(先转移,见 S09)
- 一个门店一个账号由 `UNIQUE(account_id, scope_type, scope_key)` 硬保证
10. **建员工并挂门店**:`POST .../scopes {scopeType:EMPLOYEE, scopeKey:"emp:456", parentScopeKey:"store:123", limitPoints?}`
- 服务校验 parent 门店存在、同账户、且 `sub_account_id` 由门店继承;父子关系固化入库
11. **设 / 追加限额**:`PATCH .../scopes/{scopeId}/limit {limitPoints}`(幂等 requestId)、`POST .../scopes/{scopeId}/topups {requestId, points, operatorNote, evidenceRef}`(追加 `extra`)
12. **签发子账号 CALL Key**:`POST /internal/v1/accounts/{accountId}/credentials {clientId, subAccountId, requestId}` → PENDING(24h)→ `POST /internal/v1/credentials/{id}/activate` → SaaS/对应 B 端 `ComputingKeyCipher` 加密保存
13. **该门店上的 B 端授权**:`POST /internal/v1/accounts/{accountId}/clients {clientId, status:AUTHORIZED, reason}`(仅 PLATFORM)
### 阶段 C 调用(每次请求) ### 7.1 准入顺序(`_admit` 扩展,锁序固定)
``` ```
员工/门店发起 ① 凭证(格式→摘要恒定时间比较→状态/有效期→client ACTIVE→account_client AUTHORIZED)
→ 自家 B 端后端:校验登录态与门店/员工归属(B 端主数据),解析 scope ② 幂等重放优先:命中 (account_id, client_id, request_id) 且指纹一致 → 返回既有状态。
→ 组装:messages + usageScope{type,key} + 稳定 X-Request-Id(禁止换号重试) 指纹 = 现有请求体输入 + **凭证静态主体**(credential 的 scopeId/subAccountId,Key 绑定终身不变);
→ Authorization: Bearer <CALL Key>(子账号 Key 或账户级 Key) 不含解析出的门店/子账号/月份——重放先于主体解析,换店、改派、跨月后的合法重放仍命中原记录
→ mei1_computing 准入顺序: (P0 §5:命中即返回持久化状态、指纹不含可变主数据)
① 凭证:KEY 格式 → 摘要恒定时间比较 → 状态/有效期 → client ACTIVE → account_client AUTHORIZED ③ Key→主体解析:scope 状态 ACTIVE、parent 门店 ACTIVE、所属子账号 ACTIVE;
② 幂等重放优先:命中 (accountId, clientId, requestId) 且指纹一致 → 返回既有状态(不做新准入) 员工解析 (当前门店, 子账号, 生效月额度, 当月用量) —— **单条 SQL 一次读齐**,
③ scope 归属:必须属于凭证的 sub_account_id / 账户;员工父门店一致 落在既有准入短事务内;网关派发前事务已关闭(既有纪律:HTTP 阶段无未结束事务、网络调用前关闭 Session)
④ 账户级:ACTIVE + provision READY + 无 gift_gate + 无 UNKNOWN/未核清 + 网关比例核对 ④ 账户级硬检查(不变):ACTIVE + provision READY + 无 gift_gate + 无 UNKNOWN/未核清 + 网关比例核对
⑤ 余额:子账号余额>0(子账号 Key)或账户未分配余额>0(账户级 Key) ⑤ 余额:对应桶 > 0(直挂门店/员工的桶 = 账户未分配余额,D10)
⑥ 软限额:员工档 used < limit+extra 且 门店档 used < limit+extra(两档都过) ⑥ 员工月度限额(仅员工 Key):当月已用 < 生效额度;**仅当 (员工, 当前门店) 配置行不存在或值为 NULL(不限)时跳过**——该行有具体值则照常比对(§3.3,无回退链)
⑦ REGISTERED → DISPATCHING 条件写(唯一执行权 + dispatch_deadline) ⑦ REGISTERED→DISPATCHING 条件写(唯一执行权 + dispatch_deadline);
→ 调 NewAPI(总账号令牌,一次、不重试)→ 保存正文 + X-Oneapi-Request-Id 派发前(claim)**重跑同一准入**(含 ⑥ 月额度与 scope/分桶状态):被拒则该次调用不派发(2026-09-30 决策:保持复查)。已派发调用在途照结,不受之后停用/调额影响。
→ 结算(即时或 worker 补偿):精确回捞 quota 快照 sub_account_id / scope_id / store_scope_id / quota_month 落 call 行(只作结算归属,不进指纹)
→ 单事务:账户余额 −、子账号余额 −、scope(员工).used +、scope(门店).used +、
consumption(+scope) 、point_record(CONSUME) 同事务提交;call_id 唯一防重复结算
→ 返回:consumedPoints(账户/子账号/门店/员工各自视角可查)
``` ```
### 阶段 D 结算、补偿与对账 锁序:`account` → `sub_account` → `scope`(按 scope id 升序)→ `scope_month_usage`;准入与结算两端一致,避免死锁。
14. 未结算:`202 + retryHint=QUERY_ORIGINAL_REQUEST`;客户端以原 `requestId` 查 `GET /api/v1/calls/{requestId}` **读口径一致性(2026-09-30 修复 I-01)**:可用性查询(`GET /api/v1/availability`)与真实调用**共用同一套判定**——落在 `app/account_admission.py`:`evaluate()`(账户 ACTIVE + provision READY + 无 gift_gate + 无未核清调用 + 主体状态/绑定/生效额度/当月用量 + 扣费桶余额)、`subject_of()`(Key→主体,含门店/员工/子账号级联停用)、`subject_bucket()`(扣费桶)。差异只有两处:查询侧不传 `model`(跳过网关比例核对)、`for_update=False`(不取锁)。因此:
15. 补偿 worker(`python -m app.account_worker`):SKIP LOCKED + version 租约;退避 1/5/15/60 分钟、24 次上限 → `SETTLE_FAILED` 待核清
16. 硬阻断:任一在途调用落 `UNKNOWN`/未核清 `SETTLE_FAILED` → 该**账户**(含全部子账号)新调用 `409 ACCOUNT_BLOCKED`;核清动作见 P0 §13
17. 对账恒等式:`账户未分配余额 + Σ子账号余额 = 账户可用总额`;`Σ(GIFT + TRANSFER_IN) − Σ(CONSUME) = Σ各层级余额`
18. 门店/员工分账:从本地 `consumption`(带 scope)出;网关侧只用 `token_name` 汇总商户总账,主键 `X-Oneapi-Request-Id`,过滤 `quota>0`
--- - `GET /api/v1/availability` 的 `balancePointUnits` 对子账号/门店/员工 Key 返回**该 Key 实际扣费桶**的余额(不再是账户未分配桶),`blockedReasons` 展开到主体级(`SUBJECT_DISABLED`/`PARENT_SCOPE_DISABLED`/`EMPLOYEE_QUOTA_EXCEEDED`/`DISPATCH_IN_FLIGHT` 等)。
- 契约边界:查询是**无锁快照**,不预占;并发竞争、跨请求期间的状态变化仍以调用瞬间的判定为准(`available=true` 不等于预留成功,`false` 即为当前口径下的确定性拒绝)。
- 账户级 Key(`scopeId`/`subAccountId` 均为 NULL)维持账户视角,管理 Key 亦走账户视角。
## 5. 场景矩阵(含异常、并发与边界) **取号与连接(2026-09-30 修复 I-02)**:号段分配一律在业务锁/写事务**之前**完成(`_mutate`、开户 provisioning、赠送入账三条写路径均改为"只读预检 → 取号 → 写事务内复核幂等行"),发号器使用**独立连接池**(`db_generator_pool_size` 默认 2、`db_generator_max_overflow` 默认 2),业务池容量为 1 时冷号段写请求不再依赖额外连接。幂等重放路径不依赖发号器(首个写事务内先只读命中即回执)。
### 5.1 结构与配置 ### 7.2 结算事务(单事务原子提交)
| # | 场景 | 预期行为 | 1. call 行 version 围栏 + 终态推进(现状不变);
|---|---|---| 2. 扣费桶余额条件扣减(账户桶或子账号桶);
| S01 | 开商户总账号 | 现有 P2 流程不变;账户数 = 商户数(不撞网关令牌上限) | 3. `scope_month_usage` **原子 UPSERT**:MySQL `INSERT … AS new ON DUPLICATE KEY UPDATE used_point_units = used_point_units + new.used_point_units`(行别名语法需 MySQL ≥ 8.0.19,目标 8.0.43;SQLite 测试走 `ON CONFLICT DO UPDATE` 等价实现);禁止"查-判-插"窗口(并发首条同月会撞唯一键);死锁(1213)/锁等待超时(1205)沿用既有补偿重试路径。**月份取登记快照,不取结算时刻**:跨月重试(1/31 23:59 派发、2/1 结算)计入 1 月,与准入时比对的是同一个月。代价:跨月补记会在次月初小幅修改上月已用(窗口 ≈ 在途 150s + 补偿退避 ≈ **21 小时上限**:1+5+15+60×20,第 4 次起封顶 60 分钟,节奏同 `app/jobs/settle_pending.py`);月账单按「次月 2 日(北京)后上月终态」出账(1/31 23:59 派发的最坏情形 2/1 ≈ 21:30 落账,余量 > 2 小时),报表标注数据截至最近结算;
| S02 | 账户注入额度 | 仅 PLATFORM 赠送;充值入口见 §9 D3 | 4. `consumption` 带 `sub_account_id / scope_id / store_scope_id` 落库;
| S03 | 创建子账号(重放) | 同 requestId 返回同一 subAccountId;不重复创建 | 5. `point_record(CONSUME)` 落对应桶;
| S04 | 总账号 → 子账号转账 | 冻结式扣减;超额 → 拒绝,无部分转账 | 6. 审计。
| S05 | 子账号 → 总账号回流 | 允许;成对流水;子账号余额不足 → 拒绝 |
| S06 | 子账号之间互转 | 本期不做(建议走"回流 + 再分配"),见 §9 D2 |
| S07 | 建门店 scope | `UNIQUE(account_id, scope_type, scope_key)` 保证一门店一账号 |
| S08 | 同门店挂到第二个子账号 | `409 SCOPE_CONFLICT`,必须走显式转移 |
| S09 | 门店改派子账号 | 只影响新调用;历史消费留在原处,账本不可改写;`used/limit/extra` 随门店**整体搬迁**到新子账号(推荐;见 §9 D5) |
| S10 | 建员工 scope 挂门店 | 校验父门店存在且同账户;`sub_account_id` 继承门店 |
| S11 | 员工调岗到另一门店 | 改 `parent_scope_id`;在途调用按登记快照结算;历史门店账不改 |
| S12 | 签发子账号 CALL Key | PENDING 24h;secret 仅首次返回;重放只回元数据 |
| S13 | 签发账户级 CALL Key | 保留(兼容 PC 美际分析等不分门店场景) |
| S14 | 授权/撤销 B 端使用账户 | 仅 PLATFORM;撤销后该 client 全部 Key(含重放)拒绝 |
| S15 | 追加金额(extra) | 立即可用;审计记录前后值;不影响 `limit` 与历史 |
| S16 | 限额调低到低于已用 | **允许**(与余额模型一致):`used` 保持,拒绝新调用直到追加;已受理调用仍结算,允许 `used` 超限 |
### 5.2 调用与计费 重复结算仍由 `call_id` 唯一 + version 围栏拒绝,不二次扣费(不变)。
| # | 场景 | 预期行为 |
|---|---|---|
| S17 | 员工级调用(双档) | 员工档 + 门店档都校验、都扣 `used`;扣子账号余额 |
| S18 | 门店级调用(无员工) | 只扣门店档;扣子账号余额 |
| S19 | 账户级调用(无 scope) | 扣账户未分配余额;不占门店/员工限额 |
| S20 | 员工限额不足、门店有余 | 拒绝 `429 SCOPE_LIMIT_EXCEEDED`(不消耗门店额度) |
| S21 | 门店限额不足、员工有余 | 拒绝(同上) |
| S22 | 子账号余额不足、总账号未分配有余 | 拒绝(分配即冻结,需先转账) |
| S23 | 子账号余额有余、总账号未分配为 0 | 允许(额度已在子账号内) |
| S24 | 余额不足 vs 限额不足 | 前者 `429 insufficient_quota`(OpenAI 兼容映射),后者 `429 SCOPE_LIMIT_EXCEEDED`;两者都可查具体档位 |
| S25 | 幂等重放(已完成) | 返回原 callId/状态/金额,**不做新准入**(此刻余额或限额已不足也照返) |
| S26 | 同号异参(含改 `usageScope`) | `409 FINGERPRINT_MISMATCH`(scope 进指纹) |
| S27 | B 端伪造 scope | 锚点校验拒绝(`403`/`404 SCOPE_NOT_FOUND`),越不到别的子账号 |
| S28 | 在途时改限额/调岗/改派/停用 | 在途调用不受影响,按登记快照结算 |
| S29 | 网关结果不明(UNKNOWN) | `409 ACCOUNT_BLOCKED` 阻断该账户**全部**子账号新调用;只能核清 |
| S30 | 结算延迟(SETTLE_PENDING) | `202 + retryHint`;期间 `scope.used` 未更新 → **软限额超额窗口**(退避 1/5/15/60 分钟、最多 24 次),需监控 S31 |
| S31 | 同 scope 并发调用 | 软限额允许短暂超额;超额幅度 ≈ 并发数 × 单次消耗;触发告警(scope 超额事件) |
| S32 | 子账号停用 | 其 Key 新调用 `403 ACCOUNT_DISABLED`(子账号级);在途继续结算;余额不自动回流 |
| S33 | 门店/员工 scope 停用 | 新调用 `403 SCOPE_DISABLED`;历史与在途不受影响 |
| S34 | CALL Key 轮换 | 新 Key 签发→激活→旧 Key 24h 重叠;同号请求不重复派发(幂等域不含 credentialId) |
| S35 | 撤销 Key / 撤销 client 授权 | 立即拒绝新请求(含重放);后台结算继续 |
| S36 | 账户停用 | 四层全部拒绝新调用;账务与核清不停止 |
| S37 | 删除子账号 | 有余额或有历史消费时**拒绝删除**(先转走/清零);建议只提供停用,不做物理删除 |
### 5.3 运维与对账 ### 7.3 场景矩阵
| # | 场景 | 预期行为 | 完整场景矩阵 **S01–S45 见附录 A**(沿用 v0.1 编号、行为按 v0.2 语义重写;v0.1 原文已被本文件覆盖,不另归档)。
|---|---|---|
| S38 | 恒等式核对 | `未分配 + Σ子账号 = 账户总额`;`Σ流水 = Σ余额`;残差非零告警 |
| S39 | 门店/员工分账 | 本地 `consumption` 按 scope 分页;网关侧不参与 |
| S40 | 补偿重复结算 | `call_id` 唯一 + version 围栏;重复写回被拒,不二次扣费 |
| S41 | 转账结果不明 | 保持门闩 + 审计,只核对原请求;不换号重试、不重复搬运 |
| S42 | 已分配但长期未消耗 | 需要报表支持(子账号闲置额度);不自动回流 |
--- ---
## 6. 一致性、锁与幂等(实现约束) ## 8. 报表与对账
- **门店/员工分账**(段 3 已落地):本地 `consumption` 按 `scope_id / store_scope_id / sub_account_id` 分页(ledger 投影扩列 + 三个可选维度过滤,见 P0 §7.2 分账维度);网关侧仍只出商户总账。门店账按 `store_scope_id` 聚合(门店 Key 与员工 Key 统一落归属门店),员工账按 `scope_id`,子账号账按 `sub_account_id`(NULL 即账户桶——直挂门店/员工的消费与账户级消费同桶,scope 维度保留供分账报表);账户/子账号 Key 的 `store_scope_id` 为 NULL,不参与门店聚合(§8 恒等式依赖此定义)。三个维度只在账户范围内生效,跨账户 ID 得空结果;CALL 主体传维度 → `403`。
- **月度账单**(段 3 已落地):`POST /internal/v1/consumptions/months` 直读 `scope_month_usage`(当月带生效额度与 `unlimited`;历史月只给用量,额度需 audit 重放);对账恒等式 `当月 used = Σ(consumption JOIN call ON 快照月 = month 且 SUCCESS)`。
- **分账索引口径**(段 3e,实测决定):`t_computing_call` / `t_computing_consumption` 各加两条维度索引
`(account_id, store_scope_id, create_time, id)` 与 `(account_id, scope_id, create_time, id)`。依据(隔离 MySQL 8.0.43,
单账户 10 万行、门店选择性 1%、员工 0.05%,`EXPLAIN ANALYZE`):
| 查询(LIMIT 20) | 现有索引 | 加维度索引后 |
|---|---|---|
| 门店账(1%) | 逆序读 3,983 行 / 2.80 ms | 20 行 / **0.02 ms** |
| 员工账(0.05%) | 逆序读 40,000 行 / **18.8 ms** | 20 行 / **0.01 ms** |
| 子账号桶(占行数 80%) | 25 行 / 0.73 ms | 20 行 / 0.30 ms(收益不足) |
| 账户页(无维度) | 20 行 / 0.46 ms | 20 行 / 0.50 ms(不受影响) |
1. **锁顺序固定**:`account`(含 gift_gate)→ `sub_account`(含 transfer_gate)→ `scope`(同层按 `scope_key` 升序)→ 业务行;避免死锁。 **不加**的两条与理由:① 子账号桶维度——桶通常持有账户绝大部分行,现有 `(account_id, create_time, id)` 顺扫即命中,
2. **结算事务**:新登记的 scope 扣减与现有"consumption + point_record + 余额"在**同一事务**提交;`used` 累加必须条件写(`version`)。 加索引只快 0.4 ms,不抵 ≈44 B/行的写放大;② `t_computing_scope(account_id, …)`——月账单页瓶颈在 `ORDER BY used`
3. **软限额语义**:准入只读比对;不做预留、不预扣(与 P0 §3.2 一致);超额是**已知且被接受**的窗口,用指标与告警兜底,不用"假装不会超"。 排序而非 scope 扫描(6,300 行 scope 全扫仅 1.65 ms),scope 表比 consumption 小两个数量级;等「单账户 scope 到 10⁴
4. **指纹扩展**:`usageScope` 进调用指纹;子账号归属不重复进指纹(由凭证决定)。 量级」或月账单明显变慢再评估。代价:每索引 ≈44 B/行(30 万行 ≈12.6 MB),一次结算在 call + consumption 上各多写 2 项。
5. **快照**:call 行保存登记时的 sub_account/scope 快照,结算只认快照。 - **当前对账脚本**:`scripts/sql/check.sql` 已合并基础与层级检查。B 部分保留层级检查编号:第 9 节核对调用/消费快照,第 11 节仅统计员工的登记月用量,第 13 节核对主体快照成对出现,第 14 节核对快照桶归属,第 15 节按账户与余额桶核对消费/流水。
6. **不做**:任意余额 ADJUST、跨子账号自动净额、门店/员工级凭证、子账号落网关令牌。 - 当前账户余额仅与账户桶流水核对,子账号余额分别核对;门店本身不产生员工月用量。门店停用后其下员工保留是合法稳态,对账不要求父门店一直 ACTIVE。
- `tests/test_account_scope_e2e.py` 保留双数据库的账本断言,并在 MySQL 分支实际执行完整检查 SQL。历史检查脚本原文移入 `scripts/sql/archive/`,不再要求两套脚本一起执行。
--- ---
## 7. 契约修订与改动面(实测) ## 9. 契约修订与改动面
| 项 | 内容 | | 项 | 内容 |
|---|---| |---|---|
| 需修订 | P0 §2.8(point_record 增 TRANSFER)、§2.4(credential 增 `sub_account_id`)、§2.7(call 增 scope 快照)、§5(指纹加 usageScope)、§6(权限矩阵加子账号/scope 管理)、§4 错误码表 | | 需修订 P0 | §2.4(credential 增 scope_id/sub_account_id 互斥 + CHECK)、**§2.5**(management_request 的 operation_type 增 8 个枚举 + 各操作指纹域,§6)、§2.7(call 快照列;指纹只加凭证静态主体,见 §3.5/§7.1)、**§2.8(四表合一节)**(point_record 分桶 + TRANSFER、consumption 增 3 列、operation_audit 的 target_type 扩展)、§4(错误码增量见下表;余额不足沿用 ACCOUNT_BLOCKED+reason)、§5(指纹输入加凭证静态主体 + 管理新操作指纹写全,§6)、§6(权限矩阵加子账号/scope 管理)、**§7.2「其他接口字段要点」**(约 9 条新管理路由的请求/响应形态须在此冻结,SaaS 侧才能按契约实现);**consumption/call 扩列须与《多上游与定价设计》一次性列全**(usage_raw/pricing_version_id 见其 **§3.3/§3.4**,其契约修订清单在 §5;该文档无 §10),避免两份文档互相覆盖 |
| 新增 DDL | `scripts/<日期>_account_hierarchy_and_scopes_ddl.sql`:2 表 + 扩列(沿用 `_ID`/`_identifier`/`_enum`/UTC DATETIME(3)/`_TABLE_OPTIONS` 约定,由 `app/account_models.py` 生成) | | 当前 DDL | `scripts/sql/schema.sql`:包括全部 15 张表及层级字段、索引;由 `render_account_ddl.py` 生成。系统未上线,新环境执行全量基线;旧增量原文放入 `scripts/sql/archive/` 供追溯。 |
| 代码触点 | `account_security.py`(Principal + 5 个错误码,错误码集中一处,改动小)· `account_service.py`(子账号 / scope / 限额 / 转账 / 签发带 subAccount)· `account_repository.py`(scope 归属查询)· `account_calls.py`(准入 + 快照)· `account_settlement.py`(同事务双档扣减 + 锁顺序)· `account_ledger.py`(scope 维度分页)· `account_main.py`(约 6 条新路由)· `account_models.py`(2 表 + 扩列)· `account_schemas.py`(新请求模型) | | 代码触点 | `account_security.py`(Principal 加 scope/sub 上下文 + 错误码)· `account_service.py`(子账号/transfer/scope/quota/Key 签发)· `account_repository.py`(主体解析与分桶查询)· `account_calls.py`(准入 ③⑤⑥ + 快照 + 指纹)· `account_settlement.py`(分桶扣减 + 月用量 UPSERT + 锁序)· `account_ledger.py`(scope 维度投影)· `account_main.py`(约 9 条新路由)· `account_models.py`/`account_schemas.py`(模型与请求) |
| 测试 | 新增 `tests/test_account_sub_account.py`、`tests/test_account_scopes.py`;扩展 `test_account_calls.py`/`test_account_settlement.py` 的并发与超额用例;MySQL 专项分支同样补齐 | | 测试 | 新增 `tests/test_account_sub_account.py`、`tests/test_account_scope.py`(分桶守恒、转账幂等回查、月度重置、跨月快照、换店累计、额度调整、四类 Key 矩阵、伪造/越权);扩展 `test_account_calls.py` / `test_account_settlement.py`;SQLite + MySQL 8.0.43 双全量回归(含既有 2100+ 用例不回归) |
| 未开工部分 | P5 Java / P6 前端 / P7 联调均未开始 ⇒ 现在加层级,返工成本最低(Java 侧只在未提交的 `ComputingServiceClient`/`Provision`/`Proxy` 里加字段) | | OpenAI 兼容层 | **协议零改动**:`/v1/chat/completions` 请求/响应体不变,主体由 Key 决定;仅 `openai_error` 增加一个 EMPLOYEE_QUOTA_EXCEEDED → `insufficient_quota` 映射分支 |
| 不受影响 | 网关侧运维形态不变(仍一商户一账号一令牌);现有 E2E 账户与账本无需迁移 | | 不受影响 | 网关侧一商户一账号一令牌;gift/核清/补偿状态机;存量 E2E 账户与账本 |
错误码增量(全量清单归 P0 §4;此处只列差异,余额不足沿用既有 reason 机制不新增码):
| 码 | HTTP | OpenAI type | 场景 |
|---|---|---|---|
| EMPLOYEE_QUOTA_EXCEEDED | 429 | insufficient_quota(新增映射分支) | 员工月额度不足(S20) |
| SCOPE_CONFLICT | 409 | invalid_request_error | 一店一账号冲突(S08) |
| SCOPE_DISABLED | 403 | invalid_request_error | 门店/员工 scope 停用 |
| SUB_ACCOUNT_DISABLED | 403 | invalid_request_error | 子账号停用 |
| ACCOUNT_BLOCKED + reason=INSUFFICIENT_BALANCE | 409(现状不变) | insufficient_quota(既有映射) | 余额不足 |
--- ---
## 8. 分期交付建议(小步可验收) ## 10. 分期交付(小步可验收)
| 段 | 内容 | 验收要点 | | 段 | 内容 | 验收要点 |
|---|---|---| |---|---|---|
| 1 | 子账号 + 转账 + 子账号 CALL Key | 幂等、守恒、无部分转账、`uk` 冲突收敛、子账号 Key 越权拒绝 | | 1 | 子账号 + 分桶账本 + 转账 + 子账号 Key | 幂等、守恒恒等式、无部分转账、桶隔离(子账号消费不碰未分配余额) |
| 2 | 门店/员工 scope + 归属 + 双档软限额扣减 | 一门店一账号、父子一致性、双档都校验都扣、伪造 scope 被拒 | | 2 | 门店/员工 scope + 四类 Key + 月度限额 | 一店一账号、员工月度重置/月中调整/跨月快照/换店累计、员工 Key 越权拒绝、停用路径(S32/S33:拒新调用 + 在途照常结算) |
| 3 | scope 维度账本 + 对账恒等式 + 超额/闲置告警 | 分账分页、恒等式、超额窗口可观测 | | 3 ✅ | 分账报表 + 对账脚本扩展 + 超额窗口告警 + 分账索引 | 分账分页(含三维度过滤)、恒等式核对(脚本 12–15 节 + E2E)、S30/S31 可观测(结构化 warning)、索引实测结论(§8) |
--- ## 11. 明确不做
任意余额 ADJUST(核清走既有事务)· 子账号落网关令牌 · 门店限额 · 子账号互转 · 跨子账号自动净额 · 请求参数传主体 · 跨商户 scope 迁移(换 key 重建,§3.2)· 历史月额度快照表(走 audit 重放,§3.3)。
## 9. 待确认的语义(阻塞实施) ## 12. 决策点(全部已确认,2026-09-29)
| # | 决策点 | 建议 | | # | 决策点 | 结论 |
|---|---|---| |---|---|---|
| D1 | 转账语义:**分配即冻结**(子账号只能花自己那份,总账号花未分配部分) vs 共享透支池(分配只是软标记) | 分配即冻结(字面符合"总账号把余额分配给子账号",且能防超分) | | D1 | 转账语义:分配即冻结 | 维持 v0.1 建议(已按此设计) |
| D2 | 转账权限与方向:PLATFORM only / owner INTEGRATION 也可;是否允许子账号互转 | 允许 owner INTEGRATION 做"总↔子"(不动总量);子账号互转本期不做 | | D2 | 划拨权限:owner INTEGRATION 可做总↔子;子账号互转不做 | 维持 |
| D3 | "充值/购买积分"入口是否要做(现只有 PLATFORM 赠送) | 先继续用赠送;若要充值需新增支付对账语义,另立契约 | | D3 | 充值入口 | 先继续用 PLATFORM 赠送 |
| D4 | 门店改派子账号时 `used/limit/extra` 的处理 | 整体随门店搬迁;历史消费不动 | | D4 | 门店改派时子账号桶余额处理 | 改派**不移动任何余额**(门店本身无余额);历史消费留在原桶;如需把额度迁到新子账号,走回流 + 再分配 |
| D5 | 子账号 CALL Key 是否采纳(本文 §3 建议采纳) | 采纳 | | D8 | **员工额度配置粒度**:按 (员工×门店) 记忆配置(本文 §3.3)vs 单值存员工主体 | **已确认:按 (员工×门店) 记忆配置**——换店后新店配置优先,缺失即不限(无回退链,2026-09-30 复核),从未配置不限 |
| D6 | 新增错误码与 OpenAI 兼容映射(`SCOPE_LIMIT_EXCEEDED` 等) | 按 §5.2 S24 定义 | | D9 | 每用户/每商户 Key 数量上限与监控(门店+员工 Key 规模) | **已确认:暂不做** Key 数量上限与监控;门店+员工 Key 规模可观,后续有实际需求再评估 |
| D7 | 子账号是否允许有自己的"赠送/充值"来源(平台直接给子账号发钱) | 不建议:一律先入总账号再分配,单一入口便于审计 | | D10 | 只有总账号(未建子账号)时的门店/员工调用 | **已确认(2026-09-29):支持直挂**——门店行 `sub_account_id` 可空,NULL = 直挂账户,其下门店/员工共用总账号未分配余额(员工月度额度照常生效);与账本分桶 `sub_account_id IS NULL`=账户桶天然同构;MOVE_STORE 支持直挂 ↔ 挂子账号双向改派 |
## 13. 修订记录
- **v0.1**(2026-09-29):初稿——门店/员工双档软限额 + `usageScope` 参数方案。
- **v0.2**(2026-09-29):按用户确认重写——门店无限额(C1)、员工自然月额度(C2)、换店跨店累计(C3)、未配置不限(C4)、Key 识别主体(C5);D8「员工×门店」记忆配置、D9 暂不做 Key 上限监控。
- **v0.2 复核一**(2026-09-29):外部审计 23 项修订——指纹只含凭证静态主体、签发/管理指纹写全、回退链落点与 NULL 语义、scope_key 命名空间、员工行去 sub_account_id 冗余、去 transfer_gate、门店 Key 改派免重发、月用量原子 UPSERT、余额不足沿用既有 reason 码、对账新脚本、金额口径勘误。
- **v0.2 复核二**(2026-09-29,N1–N6):门店预算口径落定为划拨约定非约束(N1)、回退排序键=最近一次设置值且范围含停用门店行(N2)、audit 重放口径修正并预留 effective_from 决策(N3)、跨行约束改应用层校验+对账兜底(N4)、P0 修订章节号补全 §2.5/§2.8/§7.2(N5)、快照取值矩阵/UPSERT 别名语法/指纹字段命名沿用/D4 措辞/month 时区例外等一致性小项(N6)。
- **v0.2 复核三**(2026-09-29,H1–H5/M1–M7):停用与建档拆分幂等域并新增 SET_SCOPE_STATUS/SET_SUB_ACCOUNT_STATUS(H1)、补 MOVE_STORE 门店改派契约入口(H2)、补偿窗口修正为 ≈21 小时(封顶 60 分钟,H3)、scope_type 不可变 + 门店 sub_account 同账户校验(H4)、对账项 4 去 ACTIVE(H5)、场景矩阵升全量附录 A 含停用路径(M1/M2)、一店一子账号收紧做法=生成列+唯一(M3)、scope_key 正则(M4)、准入 ⑥ 措辞=仅「不限」跳过(M5)、审计记录行 last_update_time 保证复算排序(M6)、《多上游》引用改 §3.3/§3.4(M7)。
- **v0.2 修复轮 I-01/I-02/P2**(2026-09-30):可用性与准入共用判定落成 `app/account_admission.py`(§7.1 读口径一致性;`availability` 的余额/拒绝原因改按 Key 扣费桶与主体级展开);号段分配前移到业务事务之前 + 发号器独立连接池(§7.1 取号与连接);凭证列表改分页 + 状态/主体筛选并补 `scopeId/subAccountId/scopeType`(§6 管理操作清单)。
- **v0.2 增补 D10**(2026-09-29):门店行 `sub_account_id` 改可空——NULL = 直挂账户,无子账号时门店/员工共用总账号未分配余额(员工月度额度不受影响);§2 模型图、§3.2 CHECK、§4.1 矩阵与快照、§5.1 分桶、§6 BIND_SCOPE/MOVE_STORE、§7.1 ⑤、附录 S46/S47 同步。
- **实现落地·段 1**(2026-09-30):子账号 + 分桶账本 + 转账 + 子账号 Key 已落地(`account_security/account_schemas/account_repository/account_service/account_calls/account_settlement/account_main` 七文件 + `tests/test_account_sub_account.py`);全量回归 2126 passed / 0 failed / 487 skipped。
- **实现落地·段 2**(2026-09-30):门店/员工 scope + 四类 Key + 员工月度限额已落地。新增 `BIND_SCOPE`/`SET_SCOPE_STATUS`/`MOVE_STORE`/`MOVE_EMPLOYEE`/`SET_SCOPE_QUOTA` 五个管理操作与 6 条路由(`POST .../scopes`、`GET .../scopes`、`.../status`、`.../sub-account`、`.../assignment`、`.../quota`),写路由一律 POST + 动作路径(`PUT .../quota` → `POST .../quota`,见 P0 头部 2026-09-30 修订);`resolve_call_subject` 单条 SQL 一次读齐(scope + parent 门店 + 分桶 + 生效额度 + 当月用量),`upsert_month_usage` 双分支原子 UPSERT,结算事务按 `call.quota_month` 快照累加(登记月非结算月),`EMPLOYEE_QUOTA_EXCEEDED` 映射 OpenAI `429 insufficient_quota`,`SET_SCOPE_QUOTA` 审计带前后值 + 写入后行 `last_update_time`(§3.3 复算排序)。新增 `tests/test_account_scope.py`(12 用例:全局唯一/父门店约束/四类 Key 快照与分桶矩阵/额度拦截不留痕/跨月重置/换店累计(新店未配置即不限,回退链已于同日复核作废)/改派不动余额/停用门槛/额度幂等与审计/UPSERT 累加/分页隔离/OpenAI 映射);全量回归(SQLite)2149 passed / 0 failed / 498 skipped;接入隔离 MySQL 8.0.43 实例后双库 2149 passed / 0 failed / **25 skipped** / 209s,473 项 MySQL 专项(行锁、原子 UPSERT、CHECK)首次真机跑通,实例启停见 `scripts/p1_mysql_instance.sh`。复核(同日):`resolve_call_subject` 的回退行改派生表 `row_number()` 表达,消除 SQLAlchemy 笛卡尔积告警,双库以 `-W error::sqlalchemy.exc.SAWarning` 重跑零告警(该回退派生表已于同日决策作废,见下条)。
- **决策落地三**(2026-09-30,用户拍板 ①1/②1/③1):① **新门店无额度配置 = 不限**——§3.3 回退链作废,`resolve_call_subject` 去掉 `latest` 派生表与 `fallback_quota` 和 `current_id`,`_scope_bucket` 只比对当前门店行的值(窗口函数依赖同时消失);② **账户级 CALL Key 仅 PLATFORM 可签**——`issue_credential` 增 `ACCOUNT_KEY_PLATFORM_ONLY`(403 `PERMISSION_DENIED`),平台签发必须显式 `clientId` 且 Key 归该 client,存量账户级 Key 不回收(§6 注、S13/S19、P0 §4/§7.2);③ **派发前复查月额度 = 保持**(无需改码,§7.1 ⑦ 后补注)。测试面:新增 `test_account_level_key_is_platform_only_but_subject_keys_stay_merchant_issued`;`test_employee_usage_follows_across_stores_...` 改断「未配置门店 = 不限 + 已用量照旧跨店累计」;全仓账户级 Key 签发点(`calling` fixture、`test_account_management`、`test_account_sub_account`、`test_account_calls`、`test_account_ledger`、`test_account_gifts` 共 13 处)改走平台签发并显式 `clientId`。
- **实现落地·段 3**(2026-09-30):分账报表 + 月账单 + 超额可观测 + 分账索引。① **3a 分账投影与过滤**:`account_ledger` 消费行增 `subAccountId/scopeId/storeScopeId`,消费查询增可选 `subAccountIds/scopeIds/storeScopeIds`(各 1–1000,账户范围内的过滤器;CALL 主体传入 `403`;gifts 传 `400`),新增 `tests/test_account_ledger_scope.py`(6 用例)。② **3b 月账单直读**:新路由 `POST /internal/v1/consumptions/months`(`ScopeMonthPageRequest`;`month` 缺省当月,历史月不带额度;CALL 主体 `403`;PLATFORM 读审计 `QUERY_MONTH_USAGE`),新增 `tests/test_account_usage_month.py`(5 用例)。③ **3c 超额/窗口可观测**:结算跨越月限额那一刻、结算重试排程、放弃结算、额度调到低于当月已用——四处结构化 warning(`key=value`,只在跨越时记一条);`quota_month()` 收敛到 `account_security`(`account_calls` 复用);新增 `tests/test_account_overage_alerts.py`(4 用例,pytest `caplog` 断言字段)。④ **3d 对账与恒等式**:对账脚本第 12 节修正 + 新增 13/14/15 节,新增 `tests/test_account_scope_e2e.py`(5 个主体真实调用后核对分账/月账/桶/流水)。⑤ **3e 分账索引**:call/consumption 各加 2 条维度索引(实测见 §8),DDL 目标脚本重生成 + 增量脚本追加。
## 附录 A:场景矩阵(S01–S45,v0.2 全量口径)
### A.1 结构与配置
| # | 场景 | 预期行为 |
|---|---|---|
| S01 | 开商户总账号 | 现有 P2 流程不变;账户数 = 商户数(不撞网关令牌上限) |
| S02 | 账户注入额度 | 仅 PLATFORM 赠送;充值入口不做(D3) |
| S03 | 创建子账号(重放) | 同 requestId 返回同一 subAccountId;不重复创建 |
| S04 | 总→子转账 | 分配即冻结(D1);超额整单拒绝、无部分转账;成对 TRANSFER_OUT/IN 同事务 |
| S05 | 子→总回流 | 允许;子账号余额不足拒绝 |
| S06 | 子账号互转 | 不做(D2),走回流 + 再分配 |
| S07 | 建门店 scope | `UNIQUE(scope_type, scope_key)` 全局保证一店一账号;scope_key 带来源前缀且过格式校验(§3.2);`subAccountId` 可省略(缺省 NULL = 直挂账户,D10) |
| S08 | 同门店挂第二个子账号 | `409 SCOPE_CONFLICT`,须显式改派(MOVE_STORE) |
| S09 | 门店改派子账号 | 门店行 `sub_account_id` 变更;旧门店 Key 自动解析新桶、**无需重发**(§4.2);不移动任何余额(D4);历史消费不改写 |
| S10 | 建员工 scope 挂门店 | 校验 parent 门店存在、同账户、ACTIVE;员工不存 sub_account_id,经 parent 解析 |
| S11 | 员工调岗 | 改 `parent_scope_id`;在途按快照结算;当月已用跨店累计(C3);生效额度按 §3.3 解析(**当前门店配置;该店无行 = 不限,无回退**) |
| S12 | 签发子账号 CALL Key | PENDING 24h;secret 仅首次返回;重放只回元数据;指纹含 `{accountId, clientId, subAccountId}` |
| S13 | 签发账户级 CALL Key | **仅 PLATFORM 可签**(2026-09-30 决策):商户自签 403 `PERMISSION_DENIED`(reason=`ACCOUNT_KEY_PLATFORM_ONLY`);不分店场景由门店直挂账户覆盖(D10);存量账户级 Key 不回收、认证与扣费不变(S19) |
| S14 | 授权/撤销 B 端使用账户 | 仅 PLATFORM;撤销后该 client 全部 Key(含重放)拒绝 |
| S15 | 调整当月额度 | 覆盖 `monthly_quota_point_units`,立即生效;审计前后值;原「追加金额」语义 = 调高当月值 |
| S16 | 额度调低于已用 | 允许:已用保持,拒新调用直到上调或次月;在途照常结算,允许 used 超限 |
### A.2 调用与计费
| # | 场景 | 预期行为 |
|---|---|---|
| S17 | 员工级调用 | 校验/扣减:员工月额度(挂载方式无关)+ 当前门店所属子账号余额,门店直挂时 = 账户未分配余额(**无双档**,C1);快照取值见 §4.1 |
| S18 | 门店级调用 | 只受所属桶约束(子账号余额或直挂时账户未分配余额);消费归属记到门店 scope(store_scope_id = 自身)供报表 |
| S19 | 账户级调用 | 扣账户未分配余额;不占任何 scope;快照全 NULL(适用于存量账户级 Key 与平台自测签发) |
| S20 | 员工月额度不足 | `429 EMPLOYEE_QUOTA_EXCEEDED`;OpenAI type=insufficient_quota(`openai_error` 新增分支);data 带 scopeId/month/limit/used(子单位十进制字符串) |
| S21 | (v0.2 取消)门店档限额 | 门店无限额(C1),此场景不存在;编号占位保持与 v0.1 可对照 |
| S22 | 子账号余额不足 | 沿用 `409 ACCOUNT_BLOCKED` + `reason=INSUFFICIENT_BALANCE`(不新增码);`data.subAccountId` 标桶;OpenAI 既有映射 429 insufficient_quota |
| S23 | 子账号有余、总未分配 0 | 允许(额度已冻结到子账号桶) |
| S24 | 余额不足 vs 限额不足 | 前者 409+reason(OpenAI → 429 insufficient_quota),后者 429 EMPLOYEE_QUOTA_EXCEEDED(OpenAI type=insufficient_quota);data 均可查具体档位 |
| S25 | 幂等重放(已完成) | 返回原 callId/状态/金额,**不做新准入**;指纹含凭证静态主体(§7.1 ②),余额/限额此刻不足也照返 |
| S26 | 同号异参 / 换 Key 主体 | `409 FINGERPRINT_MISMATCH`;同 requestId 换不同主体 Key 亦然;同主体轮换 Key 不影响重放 |
| S27 | 伪造主体 | C5 下无参数可伪造;Key 即主体,伪造 = 持有他人 Key(摘要恒定时间比较拒绝),越不到别的子账号 |
| S28 | 在途改限额/调岗/改派/停用 | 在途调用按登记快照结算(§4.1 快照矩阵),后续状态变化不影响归属 |
| S29 | 网关结果不明(UNKNOWN) | `409 ACCOUNT_BLOCKED` 阻断该账户**及全部子账号**新调用;只能核清 |
| S30 | 结算延迟(SETTLE_PENDING) | `202 + retryHint=QUERY_ORIGINAL_REQUEST`;期间 used 未更新 → 软限额超额窗口 ≈ 21 小时上限(H3 修正) |
| S31 | 同 scope 并发调用 | 软限额允许短暂超额(≈ 并发数 × 单次消耗);scope 超额事件告警 |
| S32 | 子账号停用 | 其 Key 新调用 `403 SUB_ACCOUNT_DISABLED`;**在途按快照照常结算**;余额不自动回流(需显式转账) |
| S33 | 门店/员工 scope 停用 | 新调用 `403 SCOPE_DISABLED`(员工 Key 的 parent 门店停用同样拒);**在途照常结算**;配置行保留(重新启用即恢复原值) |
| S34 | CALL Key 轮换 | 新 Key 签发→激活→旧 Key 重叠;同号请求不重复派发(同主体指纹相同,幂等域不含 credentialId) |
| S35 | 撤销 Key / 撤销 client 授权 | 立即拒绝新请求(含重放);后台结算继续 |
| S36 | 账户停用 | 四层全部拒绝新调用;账务与核清不停止 |
| S37 | 删除子账号 | 有余额或有历史消费时**拒绝删除**;只提供停用 |
### A.3 运维与对账
| # | 场景 | 预期行为 |
|---|---|---|
| S38 | 分桶恒等式核对 | §5.2 四条:账户桶/子账号桶各自守恒、转账成对守恒、总量只被 GIFT 改变;残差非零告警 |
| S39 | 门店/员工分账 | 本地 consumption 按 §4.1 快照口径分页聚合;网关侧只出商户总账 |
| S40 | 补偿重复结算 | `call_id` 唯一 + version 围栏;重复写回被拒,不二次扣费 |
| S41 | 转账结果不明 | **无门闩**(纯本地事务):按原 requestId 回查 management_request 判定已提交/未提交;不换号重试 |
| S42 | 已分配长期未消耗 | 报表支持(子账号闲置额度);不自动回流 |
| S43 | 跨月结算 | 1/31 派发、2/1 结算 → 用量记 1 月(快照月);2 月首笔自动新月 |
| S44 | 月界时区 | 月界 = 北京 0 点 = **UTC 前一日 16:00**;quota_month 按登记时间 Asia/Shanghai(唯一口径) |
| S45 | 员工月额度重置 | 次月首次调用 used 从 0 起;额度 = 届时 §3.3 解析值(C2) |
| S46 | 无子账号时的门店/员工调用(D10) | 门店直挂账户:其下门店/员工 Key 共用总账号未分配余额,无需先建子账号;消费落账户桶但保留 scope 快照,分账报表照常 |
| S47 | 直挂 ↔ 挂子账号双向改派 | MOVE_STORE 目标可 NULL:直挂门店改挂子账号后,新调用扣子账号桶(在途按快照落账户桶);反向同理;历史消费与已出账月份不改写 |
# 大模型网关架构与代码质量:问题和风险清单
## 1. 文档信息与结论
| 项目 | 内容 |
| --- | --- |
| 项目 | `mei1-computing-service` |
| 审查日期 | 2026-09-30 |
| 目标定位 | 公司统一大模型调用与计费服务,逐步替代 SaaS 后端算力账户功能 |
| 代码基线 | 本地 `master`,HEAD `f12d2992022dcc116909364a31487195ab0f18c7` **加审查时工作区未提交及未跟踪的代码**;仅检出该提交不能复现全部结论 |
| 审查方式 | 代码图谱定位、源码核验、默认测试套件、定向隔离 MySQL 测试、临时场景复现 |
| 文档状态 | 审查记录;本次未修复业务代码、未执行上线迁移 |
**总体判断:架构方向合理,账务一致性设计值得保留;当前实现仍需要补齐账户层级演进、容量隔离和工程交付,才能作为成熟的公司级网关运行。建议在现有架构上改进,无需推倒重写。**
本报告区分四类结论:
- **已复现问题**:通过隔离场景实际观察到错误或不可用行为。
- **静态确认缺口**:从源码确认存在,尚未对完整运营流程做端到端验证。
- **架构风险或产品取舍**:当前行为可能符合契约,但扩展使用范围后需要明确边界。
- **验证缺口**:尚未验证,不等于已经发现失败。
优先级:P1 为建议在相关正式接入前处理的问题;P2 为规模化使用或持续维护前需要补齐的能力。条件性风险会明确写出触发条件,不将其直接当作既有漏洞或上线事故。
## 2. 应保留的设计
1. **业务与账务职责分离。** SaaS 保留业务主数据、业务权限、账户映射、提示词和业务结果;独立服务负责凭证、模型调用、余额、账本及结算。
2. **模块化单体与本地事务。** 余额、消费记录、积分流水及员工月用量可以在同一个数据库事务内提交,现阶段不需要拆成多个微服务。
3. **外部调用不确定性处理。** 持久化幂等、派发证据、执行/计费双状态、补偿租约和版本校验,避免把超时直接等同于未执行。
4. **精确金额与历史归属。** 使用整数子单位;结算按调用登记时的主体、余额桶和月份快照执行,避免浮点误差及改派导致的历史漂移。
5. **异常路径测试。** 现有测试覆盖重复请求、取消、迟到响应、提交异常、并发结算等场景。这些防护应随重构保留。
关键参考:[调用编排](../app/account_calls.py)、[结算](../app/account_settlement.py)、[数据约束](../app/account_models.py)。
## 3. 问题与风险总表
| 编号 | 类型 | 优先级 | 问题或风险 | 当前状态 |
| --- | --- | --- | --- | --- |
| I-01 | 已复现问题 | P1 | 可用性查询与实际调用准入不一致 | **已修复(§8)** |
| I-02 | 已复现问题 | P1 | 管理事务内发号需要额外连接,可能耗尽连接池 | **已修复(§8)** |
| I-03 | 已复现问题 | P2 | 凭证累计超过 200 条后列表接口不可用 | **已修复(§8)** |
| I-04 | 静态确认缺口 | P2 | 凭证响应缺少绑定主体字段 | **已修复(§8)** |
| I-05 | 修复轮发现的既有竞态 | P2 | 并发同 requestId 对账,败者返回过期视图或误判版本冲突 | **已修复(§8)** |
| R-01 | 权限边界风险,行为已复现 | 独立主体持 Key 前优先决策 | 细分 Key 没有对应的主体级查询隔离 | 待明确契约 |
| R-02 | 容量隔离风险 | P2;全公司共用前处理 | 模型长请求与管理请求共用并发槽位 | 待设计并验证 |
| R-03 | 扩展边界 | 多上游实施前处理 | 账户、准入与结算仍深度绑定 NewAPI | 待随需求演进 |
| R-04 | 已接受的产品取舍 | 持续管理 | 不预占、不预扣,无法承诺严格预算上限 | 保留边界说明 |
| R-05 | 可维护性风险 | P2 | 服务职责集中,新增层级未贯穿所有接口 | 待渐进整理 |
| R-06 | 构建交付风险 | P2 | 依赖缺少锁定,构建不可充分复现 | 待完善 |
| R-07 | 运行保障风险 | 部署验收前处理 | 补偿调度与可观测性依赖外部部署 | 待核验部署 |
| R-08 | 契约维护风险 | P2 | 历史计划、README 与当前实现存在不同步 | 待统一文档 |
| V-01 | 验证缺口 | 正式验收前补齐 | Python 3.12、容量压测及目标部署验证不完整 | 待验证 |
## 4. 已确认的实现问题
### I-01:可用性查询与实际调用准入不一致
**位置:** [account_service.py](../app/account_service.py) 的 `current_account`(约 645 行)、`_account_view`(约 613 行);对照 [account_calls.py](../app/account_calls.py) 的 `_admit`、`_scope_bucket`。
**原因:** `current_account` 鉴权后只把账户传给 `_account_view`。该方法按账户未分配余额判断 `available`,没有按当前 Key 解析子账号、门店、员工及其生效额度。实际调用已经使用主体级准入规则。
**已复现场景:**
| 账户未分配余额 | 子账号余额 | 查询结果 | 实际调用 |
| --- | --- | --- | --- |
| 正数 | 0 | `available=true` | 409,余额不足 |
| 0 | 正数且足够本次消费 | `available=false` | 200,成功 |
**影响:** SaaS 若据此启用或禁用功能,会错误阻止合法调用,或展示可用后才遇到失败。员工限额、主体停用等判断也没有完整进入该查询方法。
**建议:** 抽取共享的主体解析与只读准入规则,让可用性查询与实际调用使用同一业务口径;查询仍不预扣、不登记调用。账户总览与当前 Key 可用性应明确区分。
**验收标准:**
- 账户、子账号、门店、员工四类 Key 均按正确余额桶返回可用性。
- 覆盖主体及上级停用、员工零额度/不限/超额、改派、未核清调用及赠送门闩。
- 在数据库和依赖状态未发生变化时,查询不得给出与相同业务准入条件相反的结论;查询结果不承诺消除并发竞争。
### I-02:管理事务内发号导致连接池资源互相等待
**位置:** [account_service.py](../app/account_service.py) 的 `_mutate`(约 191–201 行);[id_generator.py](../app/id_generator.py) 的 `SegmentIDGenerator._allocate`(约 68 行);[account_config.py](../app/account_config.py) 的连接池配置校验。
**原因:** `_mutate` 在已经鉴权、查询并锁定账户的事务中调用 `_ids`。本地号段为空或用尽时,发号器通过同一 Session 工厂开启独立事务,需要第二条数据库连接。
**已复现场景:** 使用 `pool_size=1`、`max_overflow=0` 的 SQLAlchemy QueuePool,构造一个尚未分配本地号段的服务实例,对已有账户创建子账号。业务事务持有唯一连接,发号器无法取得第二条连接,最终返回 `SERVICE_UNAVAILABLE`。该连接池大小目前通过配置校验。复现使用 SQLite 验证连接池资源行为,不是 MySQL 行锁死锁复现。
**影响:** 冷启动或号段耗尽时,管理操作可能失败。更大的连接池在并发耗尽时也存在相同资源依赖风险;这一并发放大情形尚未压测,不作为已发生事实。
**建议:** 先完成只读幂等回查,在持有业务连接或账户锁前准备新操作需要的 ID,再进入业务事务重新鉴权、回查幂等并执行。保留“历史重放不依赖号段服务”的现有保证,不以单纯增大连接池代替根因修复。
**验收标准:**
- 合法的小连接池配置下,新号段分配不因持有自己的业务连接而失败。
- 覆盖冷启动、号段边界、并发管理写入和幂等重放。
- 发号器不可用时,已存在请求仍可重放;新操作失败不留下部分业务写入。
### I-03:凭证超过 200 条后列表无法使用
**位置:** [account_service.py](../app/account_service.py) 的 `list_credentials`(约 599–611 行)。
**原因:** 查询最多取 201 条;只要超过 200 条即抛出 `CREDENTIAL_LIST_LIMIT`,没有分页,也没有状态筛选。历史已撤销凭证仍计入总数。
**已复现场景:** 在同账户、同来源下建立 201 条合法的已撤销 CALL 凭证,调用列表方法,得到上述错误。
**影响:** “每门店/员工一把 Key”及日常轮换会持续增加凭证数量;达到上限后失去列表管理能力。已知 credentialId 的撤销等接口不因此全部失效,但盘点、查找和轮换管理会受阻。
**建议:** 增加有界分页,支持状态、`scopeId`、`subAccountId` 筛选。历史凭证保留审计用途,不通过删除历史记录解决上限问题。
**验收标准:** 超过 200 条仍能完整分页查询;已撤销记录可筛选;同时间戳下排序稳定,跨账户与跨来源权限保持原有约束。
### I-04:凭证响应无法直接辨认绑定主体
**位置:** [account_service.py](../app/account_service.py) 的 `credential_view`(约 66–75 行)。
**静态事实:** 数据模型已经具有 `scope_id`、`sub_account_id`,但凭证展示函数未返回 `scopeId/subAccountId`。该函数同时用于凭证列表及管理请求结果。
**影响:** 接入方不能仅凭凭证响应确定它绑定的是哪个主体,批量盘点和恢复更依赖外部映射。
**建议与验收:** 在凭证响应中增加绑定主体字段,明确空值语义;首次签发、幂等重放、列表及状态查询保持一致,且不再次暴露 secret。该项为静态确认,未单独完成运营恢复流程测试。
## 5. 架构风险与边界
### R-01:细分 Key 的扣费隔离不等于查询隔离
**位置:** [account_calls.py](../app/account_calls.py) 的 `CallService.get`(约 111–118 行);[account_ledger.py](../app/account_ledger.py) 的 `_account_scope`、`_page`;[对外接口权限矩阵](external-api-reference.md)。
**已复现行为:** 同账户、同来源的子账号 B Key 能从消费列表取得子账号 A 的 requestId,再查询 A 的模型结果正文。查询条件主要是 `(accountId, clientId)`,没有进一步约束静态凭证主体。
**定性:** 当前文档明确采用账户+来源级查询范围,因此这是现有权限边界,不直接认定为违反契约的越权漏洞。风险在于以后将细分 Key 交给相互独立的使用方,却误认为它们已经具备数据隔离。
**建议:** 在扩大交付范围前明确:Key 是否始终由可信业务后端统一保管;调用正文、财务明细、账户总览分别需要什么读取范围。若需要主体级隔离,应从凭证推导查询范围,并覆盖列表、详情、历史回查;不能仅要求调用方传过滤条件。
**验收标准:** 权限矩阵覆盖四类 Key、同账户不同主体、不同来源、轮换及改派后的历史访问;明确哪些跨主体读取被允许,哪些必须拒绝。
### R-02:长模型调用与管理请求共享容量
**位置:** [account_main.py](../app/account_main.py) 的 `AccountBoundary.__call__`(约 103–106 行);[account_config.py](../app/account_config.py) 的 `management_concurrency`(默认 8)。
**静态事实:** 除健康检查外,入口共用一个进程内并发计数;SSE 等长请求在结束前持续占用槽位。该计数不是跨进程或跨实例的全局限流。
**风险:** 模型请求占满容量时,开户、账单、查询等请求也可能被拒绝;当前这一入口机制不能按来源或账户公平分配容量。尚未通过负载测试量化影响。
**建议:** 至少区分模型调用与管理请求容量,按实际业务增加来源或账户维度限制,并明确多实例下的配额语义。先根据压测设置容量,不先引入复杂调度系统。
**验收标准:** 一个来源持续占满长请求时,其他来源及管理入口仍达到约定的可用性目标;取消、超时后槽位最终释放;记录拒绝数、在途数和等待时长。
### R-03:多上游扩展需要先稳定计费证据边界
**位置:** [account_config.py](../app/account_config.py)、[account_calls.py](../app/account_calls.py)、[account_model_gateway.py](../app/account_model_gateway.py)、[多上游设计草案](multi-upstream-and-pricing-design.md)。
**当前事实:** 服务使用单个 NewAPI 配置及单模型白名单;账户绑定 NewAPI Token,准入核对该绑定,结算使用精确消费日志 quota。直连百炼/DeepSeek、模型目录和版本化价表属于规划。
**定性:** 这是当前能力边界,单上游场景下并非错误。风险是在新增上游时,把不同供应商的 usage、计价和重试差异直接散入账户、赠送与结算逻辑。
**建议与验收:** 随首个多上游需求引入最小的适配边界,明确模型映射、消费证据、成本价/对客价、定价版本和历史快照。缺失 usage 或受理结果不明时不得猜测费用或自动换上游重发;所有上游仍进入同一个原子账本事务。
### R-04:软限额不构成严格预算保证
**位置:** [account_calls.py](../app/account_calls.py) 的 `_admit`、`_scope_bucket`;[账户层级设计](account-hierarchy-and-scope-limits-design.md)。
**已确认取舍:** 不预占、不预扣,按已结算余额和员工月用量判断准入;员工换店后用量累计,当前门店无额度配置时不限额。
**风险:** 在途请求及结算延迟期间可能超额,金额取决于并发、单次成本和延迟,不能保证一定只是“小幅”。给员工切换到未配置额度的门店,也会解除该店的月额度约束。
**建议:** 保留已确认语义,在接入文档和产品展示中明确“软限额”;监测未结算金额风险、负余额和限额跨越。只有业务要求严格预算时,再设计预留、结算释放及异常补偿,不将硬额度列为本轮已授权改造。
### R-05:职责集中与接口演进不完整
**位置:** [account_service.py](../app/account_service.py)(审查时约 897 行)、[account_main.py](../app/account_main.py)、[account_calls.py](../app/account_calls.py)。
**观察:** `AccountService` 集中承担凭证、子账号、scope、额度、账户状态及开户恢复;跨模块使用较多字典和字符串状态。已复现的可用性问题、凭证列表上限说明新主体模型尚未贯穿所有接口。
**建议:** 随修改逐步分离账户、凭证和层级管理职责,统一主体解析和共享规则;对关键状态快照增加明确的数据结构。保留已有事务和幂等边界,不仅按行数机械拆文件,也不引入通用工作流引擎。
**验收标准:** 新增或调整主体规则时,调用、可用性、凭证响应和查询权限都有对应契约测试;重构前后的账本与状态机行为一致。
### R-06:构建依赖不可充分复现
**位置:** [pyproject.toml](../pyproject.toml)、[Dockerfile](../Dockerfile)。
**静态事实:** 多数运行依赖只声明 `>=` 下界,Docker 构建执行 `pip install .`;审查未发现相应依赖锁定文件。基础镜像使用可变化的 `python:3.12-slim` 标签。
**风险:** 同一代码在不同时间构建可能得到不同依赖组合,增加回归定位与回滚的不确定性。
**建议与验收:** 选择一种依赖锁定或约束文件机制,记录构建使用的基础镜像及依赖版本;在 Python 3.12 环境验证产物,保留可回滚镜像。无需同时维护多套依赖管理工具。
### R-07:补偿调度与可观测性需要部署闭环
**位置:** [account_worker.py](../app/account_worker.py) 的 `run/main`;[account_settlement.py](../app/account_settlement.py);[对外接口文档](external-api-reference.md) 的部署说明。
**静态事实:** worker 每次处理一批结算和结果清理后退出,需要外部周期调度。代码已有结算重试、放弃结算及额度跨越的结构化 warning;文档说明当前无指标出口。
**风险:** 若部署只启动 API 或只执行一次 worker,延迟结算与正文清理无法持续推进。仅产生日志、没有采集和告警,也不能确保运维及时发现积压。本轮未证明实际部署已经遗漏这些配置。
**建议与验收:** 明确调度频率、单批容量、失败重试、重复调度安全性和告警责任;至少监控 worker 最近成功时间、待结算数量与最老年龄、失败/未知状态数、负余额和接口拒绝率。演练停止 worker 后能告警、恢复后能推进积压且不重复扣费。
### R-08:文档阶段与实际代码不同步
**位置:** [README](../README.md)、[独立化开发计划](independent-account-development-plan.md)、[账户层级设计](account-hierarchy-and-scope-limits-design.md)、[对外接口文档](external-api-reference.md)。
**观察:** 早期计划将 SSE、子账号列为不做,当前代码已实现;README 的早期表数说明仍为 11 张,而当前 ORM 为 15 张;层级设计保留草案标识,同时包含已落地记录。
**风险:** 接入、部署和后续开发可能把历史计划当成当前契约,或把设计草案当成已实现能力。
**建议与验收:** 明确当前接口契约、实现状态和历史资料各自用途;历史计划保留但加醒目标识。主体、权限、状态机、表结构和部署方式应能从当前版本文档得到一致结论。
## 6. 验证记录与限制
| 验证项 | 本轮结果 | 限制 |
| --- | --- | --- |
| 默认测试套件 | `python3 -m pytest -q --disable-warnings`,退出码 0 | 未传 MySQL socket,相关分支跳过;双重 quiet 配置未输出最终数量,不在此估算通过数 |
| 定向隔离 MySQL | 61 passed、14 skipped、49 deselected | 只覆盖选定的账户层级与基础约束范围,不代表全量 MySQL 回归 |
| I-01 | 双向不一致均已复现 | SQLite 隔离夹具和模拟上游 |
| I-02 | 单连接池、新号段分配失败已复现 | QueuePool 资源行为;未做满载 MySQL 并发压测 |
| I-03 | 201 条历史撤销凭证导致列表失败已复现 | SQLite 隔离数据 |
| R-01 | 同 account/client 不同子账号互查消费与正文已复现 | 符合当前文档范围,是否收窄需决策 |
| 运行时 | Python 3.9.6 | 项目目标为 Python 3.12,本轮未验证目标运行时 |
| 外部环境 | 未调用真实模型、未访问业务数据库 | 不能据此声明生产链路或部署验收通过 |
| 修复轮 I-01 | 四类 Key × 双向不一致场景用例全绿;查询与实际调用同口径 | 无锁快照,不消除并发竞争 |
| 修复轮 I-02 | 业务池 1 连接 + 冷号段写请求 200(修复前 503);幂等重放不依赖发号器 | 未做满载并发压测 |
| 修复轮 I-03/I-04 | 215 条凭证(含 95 已撤销)可分页 + 状态/主体筛选;响应补绑定主体字段 | 双库(SQLite / 隔离 MySQL) |
| 修复轮 I-05 | 3 线程并发对账 16/16 轮一致(修复前约 1/5 轮异常);护栏用例连跑 3 次通过 | 仅覆盖 `RECONCILE` 入口 |
| 修复轮全量回归 | 隔离 MySQL:2181 通过 / 0 失败 / 30 跳过 / 207.1s;SQLite:1677 通过 / 0 失败 / 534 跳过 / 69.2s(均 2211 用例) | 运行时仍为宿主 Python 3.9.6,3.12 待验证(V-01) |
定向 MySQL 验证命令如下,socket 必须指向符合测试夹具约束的专用临时实例。测试会创建并清理随机测试库,禁止替换为业务实例:
```bash
python3 -m pytest -o addopts='' -q \
tests/test_account_sub_account.py \
tests/test_account_scope.py \
tests/test_account_scope_e2e.py \
tests/test_account_foundation.py \
-k mysql \
--account-mysql-socket=/private/tmp/computing-p1-mysql.dev/mysqld.sock \
--disable-warnings --tb=short --maxfail=1
```
临时场景脚本保存在审查机器的 `/private/tmp/mei1-gateway-review-20260930/test_review_repro.py`,未纳入项目,临时目录可能被清理。脚本断言用于确认当前问题行为,不是修复后的正确性验收测试;修复时应将其转换为正式的期望行为回归用例。
代码图谱代次为 `2026-09-30T06:15:48Z`。本轮涉及的主要源码路径未记录解析缺口且元数据匹配;文档及部署文件通过文件读取核验。图谱覆盖信号不是完整性证明,本报告也不是全仓逐行安全审计。
**V-01 待补验证:** Python 3.12 产物与测试、目标 MySQL 版本/权限/迁移、真实上游及反向代理 SSE、持续补偿调度、容量与连接池压测。历史文档中的验证记录不能自动替代当前版本验收。
## 7. 建议处理顺序
1. **相关业务正式接入前:** ~~修复 I-01、I-02~~(2026-09-30 已修复,见 §8);确定 R-01 的 Key 交付与查询权限边界。
2. **门店/员工 Key 批量推广前:** ~~完成 I-03、I-04~~(2026-09-30 已修复,见 §8),同步权限、主体响应和查询契约。
3. **公司多业务共用及部署验收前:** 处理 R-02、R-06、R-07,补齐 V-01,并保持 R-04 的产品边界透明。
4. **持续演进:** 渐进处理 R-05、R-08;实施多上游时再处理 R-03,保留既有账务一致性防护。
以上为建议顺序,不代表已安排负责人、开发排期或已批准上线。各项关闭时应补充修复版本、验证证据和仍保留的限制。
## 8. 修复记录(2026-09-30 修复轮)
本轮修掉 §4 的四个实现问题,并顺带修掉一处同轮发现的既有竞态(I-05)。全部改动都跑了 SQLite + 隔离 MySQL 8.0.43 双库全量回归(`-W error::sqlalchemy.exc.SAWarning`)。
| 编号 | 修复内容 | 主要文件 | 定向证据 |
| --- | --- | --- | --- |
| I-01 | 可用性与准入共用同一套判定:新增 `account_admission.evaluate/subject_of/subject_bucket`,`_account_view(principal=...)` 与 `_admit`/`_scope_bucket` 同源;`availability` 的余额与拒绝原因改按 Key 扣费桶、主体级展开 | [account_admission.py](../app/account_admission.py)(新增)、[account_calls.py](../app/account_calls.py)、[account_service.py](../app/account_service.py) | 修复前:子账号桶 0、账户桶 146 → 查询 `available=true` 而实际调用 409;反向(桶全划子账号)→ 查询 `available=false` 而实际 200。修复后两侧一致;员工零额度、门店停用、上级停用、改派、未核清调用、赠送门闩均纳入 |
| I-02 | ① 号段分配前移到业务锁之前(`_mutate` 拆出 `_guard`:只读预检 → 取号 → 写事务内复核幂等行;开户 provisioning 与赠送入账同样前移)② 发号器改用独立连接池(`db_generator_pool_size`/`db_generator_max_overflow`,默认 2+2;web 与 worker 两处装配) | [account_service.py](../app/account_service.py)、[account_gifts.py](../app/account_gifts.py)、[db.py](../app/db.py)、[account_config.py](../app/account_config.py)、[account_main.py](../app/account_main.py)、[account_worker.py](../app/account_worker.py) | 业务池 1 连接 + 进程内无号段:修复前 503,修复后 200;发号器完全不可用(工厂抛错)时同 requestId 重放仍 200;全库扫描"持有连接/锁期间取号"0 处(原 5 处) |
| I-03 | 凭证列表改分页(`page`/`size`,每页上限 200)+ `status`/`subAccountId`/`scopeId` 筛选,出参 `{total,page,size,list}` | [account_service.py](../app/account_service.py) | 215 条(含 95 已撤销)可逐页取回;`status=REVOKED`、`subAccountId`、`scopeId` 各自筛选命中;`size` 越界 → 400 `INVALID_ARGUMENT` |
| I-04 | `credential_view` 补 `scopeId`/`subAccountId`/`scopeType`(scope 类型批量查询),签发/激活/撤销/列表/幂等重放五处同口径 | [account_service.py](../app/account_service.py) | 出参绑定对象与库中一致;`secretMask` 等既有字段不变,未新增 secret 暴露面 |
| I-05 | **同轮发现的既有竞态**:并发同 `requestId` 对账时,败者可能返回过期视图(`operationStatus=SUCCEEDED` 却带提交前的 `version`/`phase`,HTTP 202),或误判版本冲突(409)。根因:`_reconcile_state` 先读 gift 行、后查幂等行,跨提交边界时"幂等行已提交、gift 视图未刷新"。修复:命中重放时重读该行(`populate_existing`);版本不一致时在新事务复查幂等行兜底重放 | [account_gifts.py](../app/account_gifts.py) | 3 线程并发对账:修复前 8 轮探针 2 轮异常、16 轮探针 9 轮异常;修复后 16/16 轮全部 200 且 version/phase 一致;新增护栏用例 `test_mysql_concurrent_reconcile_replay_never_returns_stale_view`(3 轮)连跑 3 次通过,原用例连跑 6 次通过 |
契约口径同步(对外行为):
- `GET /api/v1/availability` 是**无锁快照**:不预占、不登记调用;`available=true` 不承诺并发下必然成功,`available=false` 表示当前口径下的确定性拒绝。子账号/门店/员工 Key 的 `balancePointUnits` = 该 Key 实际扣费桶余额(此前为账户未分配桶),并新增桶与主体字段;账户级 Key 维持账户视角。
- 列表类接口每页上限 200,超出由调用方翻页;响应新增字段向后兼容,**不参与幂等指纹**(指纹兼容不变式保持)。
- 号段分配仍在业务锁之前(P0 硬约束不变);发号器独立连接池不改变单进程号段语义。
- 并发对账回执:胜者与败者返回同一终态视图(version/phase 一致)。
文档同步:`docs/external-api-reference.md`(§2 分页约定、§3.5 账户概览字段口径、§4 凭证列表参数)、`docs/account-hierarchy-and-scope-limits-design.md`(§7.1 读口径一致性 + 取号与连接、§13 修订记录)、`README.md`(新增两个配置项)。
回归数字(双库全量,共 2211 用例):
- 隔离 MySQL 8.0.43:**2181 通过 / 0 失败 / 0 错误 / 30 跳过 / 207.1s**
- SQLite:**1677 通过 / 0 失败 / 0 错误 / 534 跳过 / 69.2s**
- 修复轮前基线:2197 用例 / 0 失败(MySQL);本轮新增护栏用例后为 2211。
仍保留的限制:I-02 只验证了根因路径(冷号段、单连接池)与幂等重放,**未做满载并发压测**;I-01 不消除并发竞争;I-05 只覆盖 `RECONCILE` 入口(`gift`/`RETRY_UNSENT` 等未逐一造竞态);R-01、R-02、R-05~R-08、V-01 均未处理。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>mei1-computing-service 接入全链路流程图</title>
<style>
.wrap { width: 1040px; max-width: 1040px; font-size: 13px; line-height: 1.5; }
h3 { margin: 0 0 6px; font-size: 15px; font-weight: 650; }
.sub { color: var(--muted-foreground); font-size: 12px; margin: 0 0 12px; }
.lane { border: 1px solid var(--border); border-radius: 10px; padding: 12px; background: color-mix(in srgb, var(--card) 70%, transparent); }
.phase { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; align-items: stretch; }
.step { border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.step .h { font-weight: 650; margin-bottom: 4px; }
.step .m { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--muted-foreground); word-break: break-all; }
.step .n { font-size: 11.5px; color: var(--muted-foreground); margin-top: 4px; }
.arrow { display: flex; align-items: center; justify-content: center; color: var(--muted-foreground); font-size: 18px; width: 26px; }
.gr { border-left: 3px solid #3fb950; } .am { border-left: 3px solid #d29922; } .rd { border-left: 3px solid #f85149; }
.bl { border-left: 3px solid #58a6ff; } .gy { border-left: 3px solid var(--border); }
.tag { display: inline-block; padding: 0 6px; border-radius: 999px; font-size: 10.5px; font-weight: 650;
border: 1px solid var(--border); color: var(--muted-foreground); margin-right: 4px; }
.tag.pl { border-color: #d29922; color: #d29922; }
.tag.in { border-color: #58a6ff; color: #58a6ff; }
.tag.ck { border-color: #3fb950; color: #3fb950; }
.flow { display: flex; flex-direction: column; gap: 0; }
.row { display: grid; grid-template-columns: 190px 26px 1fr; align-items: center; }
.row .lane2 { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.row .hd { font-weight: 600; font-size: 12.5px; }
.branch { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-top: 8px; }
.out { border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px; font-size: 12px; }
.out b { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
th, td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: color-mix(in srgb, var(--foreground) 6%, transparent); font-weight: 650; }
code { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; }
.ok { color: #3fb950; font-weight: 650; } .warn { color: #d29922; font-weight: 650; } .no { color: #f85149; font-weight: 650; }
.foot { color: var(--muted-foreground); font-size: 11.5px; margin-top: 10px; }
.chip { display: inline-block; border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px;
font-size: 12px; cursor: pointer; margin: 6px 6px 0 0; }
.chip:hover { border-color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<h3>① 开通与层级(账户 → 子账号 → 门店 → 员工 → Key)</h3>
<p class="sub">蓝色 = 商户可自助(INTEGRATION Key,限自有账户);橙色 = 仅平台(PLATFORM Key);绿色 = CALL Key 调用侧</p>
<div class="lane">
<div class="phase">
<div class="step am">
<div class="h">1. 开户</div>
<div class="m">POST /internal/v1/accounts</div>
<div class="n">{name, remark} → accountId<br>provisionStatus: PENDING → READY(绑定网关令牌)</div>
</div>
<div class="arrow">▶</div>
<div class="step am">
<div class="h">2. 充值(必须有余额)</div>
<div class="m">POST /internal/v1/gifts</div>
<div class="n">{accountId, clientId, points, exclusiveWriterConfirmed:true, exclusivityEvidenceRef}<br>
账户未分配余额 += points×10000 子单位<br>仅 <b>phase=CONFIRMED</b> 算完成</div>
</div>
<div class="arrow">▶</div>
<div class="step bl">
<div class="h">3. 建子账号</div>
<div class="m">POST /internal/v1/accounts/{accountId}/sub-accounts</div>
<div class="n">{name, remark?} → subAccountId<br>新建桶余额 = 0</div>
</div>
<div class="arrow">▶</div>
<div class="step bl">
<div class="h">4. 划拨余额(分配即冻结)</div>
<div class="m">POST .../sub-accounts/{subAccountId}/transfers</div>
<div class="n">{points, direction:"OUT"}(OUT=账户→子)<br>账户桶条件扣减 + 子桶条件累加,同事务<br>
任一侧不足 → 409 <b>整单拒绝,无部分转账</b></div>
</div>
<div class="arrow">▶</div>
<div class="step bl">
<div class="h">5. 建门店 / 员工 / 额度</div>
<div class="m">POST .../scopes<br>POST .../scopes/{scopeId}/quota</div>
<div class="n">门店:{scopeType:"STORE", scopeKey, subAccountId?}(省略=直挂账户)<br>
员工:{scopeType:"EMPLOYEE", scopeKey, parentScopeKey:门店}<br>
额度:{storeScopeId, monthlyQuotaPoints|null}(0=零额度,null=显式不限)</div>
</div>
</div>
</div>
<h3 style="margin-top:16px">② 签发 Key 并激活</h3>
<div class="lane">
<div class="phase" style="grid-template-columns: repeat(5, 1fr)">
<div class="step bl">
<div class="h">6. 签发</div>
<div class="m">POST .../accounts/{accountId}/credentials</div>
<div class="n">{subAccountId? | scopeId?}(二选一,互斥)<br>
→ credentialId + <b>secret(仅此一次)</b>,status=PENDING(24h 内有效)</div>
</div>
<div class="arrow">▶</div>
<div class="step am">
<div class="h">7. 激活</div>
<div class="m">POST /internal/v1/credentials/{credentialId}/activate</div>
<div class="n">{replacesCredentialId?} → status=ACTIVE<br>轮换:旧 Key 宽限期 min(原值, now+24h)</div>
</div>
<div class="arrow">▶</div>
<div class="step gr">
<div class="h">Key 四类(决定主体与扣费桶)</div>
<div class="m">账户级 | 子账号级 | 门店级 | 员工级</div>
<div class="n">账户级 = 平台/测试专用(商户自签 403 <b>ACCOUNT_KEY_PLATFORM_ONLY</b>)<br>
商户路径:门店 Key(无子账号时直挂账户)或员工 Key(受月度额度约束)</div>
</div>
<div class="arrow">▶</div>
<div class="step gy">
<div class="h">调用时不需要传主体</div>
<div class="m">主体 = Key 绑定值;分账维度不可由请求参数覆盖</div>
<div class="n">CALL Key 传 accountIds/子账号/门店维度 → 403 <b>PERMISSION_DENIED</b></div>
</div>
</div>
</div>
<h3 style="margin-top:16px">③ 一次模型调用的服务端内部流程(含 SSE 分支)</h3>
<div class="lane">
<div class="flow">
<div class="row">
<div class="hd"><span class="tag ck">CALL Key</span>请求</div>
<div class="arrow">▶</div>
<div class="lane2"><b>POST /v1/chat/completions</b>(OpenAI 兼容,可带 stream:true)或 <b>POST /api/v1/chat/completions</b>(原生)<br>
<span class="m">Authorization: Bearer &lt;CALL Key&gt; X-Request-Id: ≥8 字符(/v1 可省,服务端生成)</span></div>
</div>
<div class="row">
<div class="hd">1. 鉴权</div>
<div class="arrow">▼</div>
<div class="lane2">凭证存在 → 摘要恒定时间比较 → 状态/有效期 → client/account 授权
<div class="branch">
<div class="out rd">401 <b>CREDENTIAL_PENDING/EXPIRED/REVOKED</b>、AUTHENTICATION_FAILED</div>
<div class="out rd">403 <b>ACCOUNT_ACCESS_REVOKED</b>(撤销优先于重放)</div>
<div class="out gy">通过 → 下一步</div>
</div>
</div>
</div>
<div class="row">
<div class="hd">2. 幂等命中?</div>
<div class="arrow">▼</div>
<div class="lane2">按 (accountId, clientId, requestId) 查原记录 + 比对指纹
<div class="branch">
<div class="out gr">同号同参 → <b>直接返回历史结果</b>(200/202),不派发、不重复扣费</div>
<div class="out rd">同号异参 → 409 <b>FINGERPRINT_MISMATCH</b></div>
<div class="out gy">无记录 → 新请求</div>
</div>
</div>
</div>
<div class="row">
<div class="hd">3. 准入检查(只读比对)</div>
<div class="arrow">▼</div>
<div class="lane2">账户/子账号/门店/员工状态 + 余额 + 员工当月额度
<div class="branch">
<div class="out rd">403 <b>ACCOUNT_DISABLED / SUB_ACCOUNT_DISABLED / SCOPE_DISABLED</b></div>
<div class="out rd">409 <b>ACCOUNT_BLOCKED</b>(reason=INSUFFICIENT_BALANCE,可带 subAccountId)</div>
<div class="out am">429 <b>EMPLOYEE_QUOTA_EXCEEDED</b>(带 quotaMonth/limitPointUnits/usedPointUnits)<br>无 429 命中即放行</div>
</div>
</div>
</div>
<div class="row">
<div class="hd">4. 登记 + 派发</div>
<div class="arrow">▼</div>
<div class="lane2">落 <code>call</code> 行(主体快照 subAccountId/scopeId/storeScopeId + quotaMonth = 登记月 Asia/Shanghai)
→ 调上游(NewAPI/直连),调用预算 150 秒
</div>
</div>
<div class="row">
<div class="hd">5. 结果与结算</div>
<div class="arrow">▼</div>
<div class="lane2">以网关日志 <code>quota</code> 为权威计量:<b>consumedPointUnits = max(quota,0) × 146</b>
<div class="branch">
<div class="out gr">已结算 → 200 / OpenAI 正常响应:<code>billingStatus=SUCCESS, settled=true</code>,同事务写 consumption + point_record(扣快照桶)+ 余额</div>
<div class="out am">日志延迟 → <code>SETTLE_PENDING</code> → 补偿重试 1/5/15/60 分钟 ×24(≈21.35h)</div>
<div class="out am">结果不明 → 202 + <code>retryHint=QUERY_ORIGINAL_REQUEST</code>,用原 requestId 查 <code>GET /api/v1/calls/{requestId}</code></div>
</div>
</div>
</div>
<div class="row">
<div class="hd"><span class="tag ck">SSE</span>stream:true 分支</div>
<div class="arrow">▼</div>
<div class="lane2">
<div class="branch">
<div class="out gy">首个事件前失败 → <b>仍返回 OpenAI JSON 错误</b>(400/401/403/409/429/5xx)</div>
<div class="out am">已发响应头后失败 → <code>data: {"error": …}</code> 并结束,<b>不发 [DONE]</b>,含 X-Should-Retry:false</div>
<div class="out gr">正常 → 增量 <code>delta</code>(role/content/reasoning_content)→ <code>finish_reason</code> → <code>data: [DONE]</code></div>
</div>
<div class="n" style="margin-top:6px">[DONE] <b>只表示生成成功</b>,不代表已结算;账务与最终费用仍查 <code>GET /api/v1/calls/{requestId}</code>。同号重放且原执行仍 PENDING/PROCESSING → JSON 409(reason=ORIGINAL_REQUEST_PENDING),绝不重新派发。</div>
</div>
</div>
</div>
</div>
<h3 style="margin-top:16px">④ 是否满足 OpenAI 协议(按代码实测口径)</h3>
<div class="lane">
<table>
<tr><th style="width:150px">类别</th><th>字段 / 行为</th><th style="width:210px">结果</th></tr>
<tr>
<td class="ok">支持(严格校验)</td>
<td><code>model</code>(可省)、<code>messages</code>(system/user/assistant,<code>developer</code> 映射为 system;content 为字符串 1–1MiB,1–50 条)、<code>stream</code>(严格布尔)、<code>stream_options.include_usage</code>、<code>temperature</code> 0–2、<code>top_p</code> 0–1、<code>max_tokens</code>/<code>max_completion_tokens</code> 1–1e7、<code>presence_penalty</code>/<code>frequency_penalty</code> −2–2、<code>seed</code> 0–2³¹−1、<code>stop</code>(≤4 条,每条 1–64 字符)</td>
<td class="ok">✔ 透传到上游</td>
</tr>
<tr>
<td class="warn">忽略(不报错)</td>
<td><code>n</code>、<code>tools</code>/<code>tool_choice</code>、<code>response_format</code>、<code>logit_bias</code>、<code>user</code>、<code>metadata</code> 等一切白名单外字段</td>
<td class="warn">⚠ 静默丢弃,不会生效,也不会 400</td>
</tr>
<tr>
<td class="no">拒绝(400)</td>
<td><code>messages[].content</code> 数组形式(多模态)、<code>stream</code> 非布尔、<code>stream_options</code> 中出现 include_usage 以外的键、非流式请求带非 null 的 stream_options、<code>stop</code> 超 4 条或单条超 64 字符</td>
<td class="no">✘ invalid_request_error</td>
</tr>
<tr>
<td class="ok">响应形态</td>
<td><code>id=chatcmpl-&lt;requestId&gt;</code>、<code>object=chat.completion</code>/<code>chat.completion.chunk</code>、<code>created</code>、<code>model</code>、<code>choices[{index, message|delta, finish_reason}]</code>、<code>usage{prompt_tokens, completion_tokens, total_tokens}</code>,另加扩展块 <code>mei1{requestId, callId, billingStatus, consumedPoints}</code>(官方 SDK 会忽略未知字段)</td>
<td class="ok">✔ SDK 可直接解析</td>
</tr>
<tr>
<td class="ok">错误形态</td>
<td><code>{error:{message, type, code, param}}</code>;余额不足与员工超额 → 429 <code>insufficient_quota</code>;限流 → 429 <code>rate_limit_error</code>;执行未出正文 → 500/503 <code>api_error</code>;<code>/v1/*</code> 的入参校验失败也走 OpenAI 形态</td>
<td class="ok">✔ 与 OpenAI 同构</td>
</tr>
<tr>
<td class="warn">必须知道的差异</td>
<td>
1. <b>单模型白名单</b>:<code>model</code> 必须等于服务端配置值,否则 400(不是「模型不存在」而是参数非法)<br>
2. <b>无幂等头的重试会重复计费</b>:/v1 省略 X-Request-Id 时服务端每次生成新号,请自行带业务请求号并关闭自动重试<br>
3. <b>仅字符串 content</b>:多模态、工具调用、JSON mode 均不可用<br>
4. <b>计费不看 usage</b>:扣费以网关日志 quota 为准,<code>usage</code> 仅为展示;include_usage 的尾部用量事件可能缺失<br>
5. 429 <code>insufficient_quota</code> 有两种含义(账户/子账号余额不足 vs 员工月度额度超限),区分看 <code>error.mei1.reason</code><br>
6. 错误 <code>code</code> 为自有错误码(如 ACCOUNT_BLOCKED),<code>message</code> 可能带 <code>: reason</code> 后缀
</td>
<td class="warn">⚠ 兼容但非等价</td>
</tr>
<tr>
<td class="gy">入口</td>
<td><code>POST /v1/chat/completions</code>(非流式 + SSE)、<code>GET /v1/models</code>(单模型,仅供探活/枚举)</td>
<td class="gy">迁移只需改 base_url 与 Key</td>
</tr>
</table>
<div class="foot">依据:<code>app/account_openai.py</code>(请求模型、响应与错误构造)、<code>app/account_main.py</code>(路由与异常处理)、<code>docs/external-api-reference.md</code> §3.2 与 §7.2。未做真实网关联调的部分(上游 SSE 契约、目标环境反代)未包含在内。</div>
</div>
<div class="chip" data-hermes-send="把 SSE 时序展开成逐事件的时序图(含断连与重放分支)">展开 SSE 时序</div>
<div class="chip" data-hermes-send="生成一份可直接导入的 Postman collection,覆盖开户→转账→签发→调用→查账">导出 Postman collection</div>
<div class="chip" data-hermes-send="把这张流程图导出为可交付的单文件 HTML(带目录与打印样式)">导出单文件 HTML</div>
</div>
</body>
</html>
# mei1-computing-service 对外接口文档
| 项 | 值 |
| --- | --- |
| 适用对象 | 外部接入方(SaaS 后端、B 端业务系统、运营/结算脚本的对接开发者) |
| 服务 | `mei1-computing-service`(独立算力账户 + 计费网关) |
| 文档版本 | v1.0(2026-09-30) |
| 权威来源 | 本文档由代码与契约文档生成:`app/account_main.py`(路由)、`app/account_schemas.py`(入参)、`app/account_ledger.py` 等(出参)、`docs/p0-contract-freeze.md`(契约冻结)、`docs/account-hierarchy-and-scope-limits-design.md`(层级与限额)。**代码与本文不一致时以代码为准**,并请报缺陷 |
| 覆盖范围 | 当前已实现并验收的 35 个操作(32 条路径 + `/health/*`),清单见附录 C |
> **接入前必读**:§2(通用约定)、§5(账户层级与限额)、§6(记账与对账口径)。这三节是外部系统最容易踩坑的地方。
---
## 0. 快速上手
### 0.1 接入流程(六步)
```
① 平台侧开户 POST /internal/v1/accounts → accountId
② 平台侧充值 POST /internal/v1/gifts → 账户余额 > 0
③(可选)建子账号 POST /internal/v1/accounts/{id}/sub-accounts → subAccountId
并划拨余额 POST .../sub-accounts/{sid}/transfers {direction:OUT, points}
④(可选)建门店/员工 POST /internal/v1/accounts/{id}/scopes → scopeId
(员工需带 parentScopeKey,可再设月额度 POST .../scopes/{sid}/quota)
⑤ 签发调用 Key POST /internal/v1/accounts/{id}/credentials → credentialId + secret(仅此一次)
⑥ 发起模型调用 POST /api/v1/chat/completions 或 /v1/chat/completions
查账 GET /api/v1/calls/{requestId}、POST /api/v1/consumptions/page
```
- ① ② 属于 **PLATFORM 权限**(平台/运营侧);③④⑤ 可由商户自己的 **INTEGRATION Key** 自助完成(限自有账户)。
- **商户不能自签账户级 Key**(见 §2.7),商户侧请走门店/员工 Key;没有子账号时门店直接挂账户(设计 D10)。
### 0.2 最小可用示例
```bash
BASE=https://<目标环境地址> # 目标环境地址待落地配置,见附录 D
# ① 开户(PLATFORM Key)
curl -sS -X POST "$BASE/internal/v1/accounts" \
-H "Authorization: Bearer $PLATFORM_KEY" \
-H "X-Request-Id: open-account-0001" \
-H 'Content-Type: application/json' \
-d '{"name":"某某美容院","remark":"华北区"}'
# → {"success":true,"code":"OK","requestId":"open-account-0001","data":{"accountId":"100000000000000001","provisionStatus":"READY",...}}
# ② 充值(PLATFORM Key,单位=积分)
curl -sS -X POST "$BASE/internal/v1/gifts" \
-H "Authorization: Bearer $PLATFORM_KEY" -H "X-Request-Id: gift-0001-abcd" \
-H 'Content-Type: application/json' \
-d '{"accountId":"100000000000000001","clientId":"mei1-saas","points":1000,
"exclusiveWriterConfirmed":true,"exclusivityEvidenceRef":"ticket-2026-0930"}'
# ③ 签发门店 Key(INTEGRATION Key = 商户自己)
curl -sS -X POST "$BASE/internal/v1/accounts/100000000000000001/credentials" \
-H "Authorization: Bearer $INTEGRATION_KEY" -H "X-Request-Id: issue-store-key-01" \
-H 'Content-Type: application/json' \
-d '{"scopeId":"100000000000000900"}'
# → {"data":{"credentialId":"...","secret":"ck-....",...}} secret 只出现这一次
# ④ 调用(CALL Key)
curl -sS -X POST "$BASE/api/v1/chat/completions" \
-H "Authorization: Bearer $CALL_KEY" -H "X-Request-Id: biz-20260930-0001" \
-H 'Content-Type: application/json' \
-d '{"businessCode":"CHAT","businessRef":"order-123","messages":[{"role":"user","content":"你好"}]}'
```
---
## 1. 服务概览
### 1.1 三个入口平面
| 平面 | 前缀 | 凭证 | 用途 |
| --- | --- | --- | --- |
| 业务平面 | `/api/v1/*` | **CALL Key** | 本账户余额/可用性、发起模型调用、查询本来源的调用与消费 |
| OpenAI 兼容平面 | `/v1/*` | **CALL Key** | 只改 `base_url` + Key 即可从直连大模型迁入(含 SSE 流式) |
| 管理平面 | `/internal/v1/*` | **INTEGRATION / PLATFORM Key** | 开户、签发、层级(子账号/门店/员工)、额度、赠送、账本、核清 |
运维探活:`GET /health/live`(进程存活,永远 200)、`GET /health/ready`(DB 可连且网关换算比例正确;不可用返回 503 `DEPENDENCY_UNAVAILABLE`)。
### 1.2 一次调用的两阶段
```
登记(幂等,落 call 行)→ 派发上游(NewAPI / 直连)→ 结算(按网关日志 quota 扣费、写 consumption + point_record)
```
- HTTP 200 **只代表本次处理/查询成功**,不代表模型成功、也不代表结算成功。
- 登记成功但结果未定 → **HTTP 202 + `code: "ACCEPTED"`** + `retryHint: "QUERY_ORIGINAL_REQUEST"`;此时唯一安全动作是用同一 `X-Request-Id` 查询原请求。
- 服务端**永不自动重发**模型调用;也**永不返回"可换号重发"**。
### 1.3 无状态与并发
服务本身无会话状态;所有状态在库里(调用、账务、幂等登记、审计)。并发上限达限返回 429 `RATE_LIMITED`(可重试)。
---
## 2. 通用约定
### 2.1 鉴权
```
Authorization: Bearer <secret>
```
`secret` 是签发时返回的 CALL/管理 Key 原文。CALL Key 由平台或商户签发;管理 Key 由受控 bootstrap 或平台签发。
**Key 类型决定调用主体,不接受请求参数指定主体**:
| CALL Key 类型 | 绑定 | 调用主体 | 扣费桶 | 是否受月度额度限制 |
| --- | --- | --- | --- | --- |
| 账户级 | `subAccountId`、`scopeId` 均为空 | 账户 | 账户未分配余额 | 否 |
| 子账号级 | `subAccountId` | 子账号 | 该子账号桶 | 否 |
| 门店级 | `scopeId` = STORE | 门店 | 门店所属子账号桶;门店直挂账户时为账户桶 | 否 |
| 员工级 | `scopeId` = EMPLOYEE | 员工(所属门店由服务端解析) | 同上门店口径 | **是**(月中可调,见 §5.3) |
### 2.2 请求头
| 头 | 必带 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <secret>` |
| `X-Request-Id` | **写操作必带** | ASCII 可打印 8–100 字符(正则 `^[!-~]{8,100}$`),无空白。作为幂等号 |
| `Content-Type` | 有请求体时 | `application/json`(OpenAI 平面同) |
- 重复的 `Authorization` / `X-Request-Id` / `Content-Length` 头 → 400 `INVALID_ARGUMENT`。
- `X-App-Id` 不再作为身份输入,出现仅记录不采信。
- **不需要** `X-Request-Id` 的接口:全部 `GET`,以及下列只读 POST:
`/internal/v1/accounts/query`、`/internal/v1/gifts/page`、`/internal/v1/consumptions/page`、`/internal/v1/consumptions/months`、`/api/v1/consumptions/page`、`/api/v1/account/availability`。
(`/v1/chat/completions` 也允许省略——OpenAI 协议无幂等头;缺省时服务端生成请求号,重试可能重复计费。)
- 写操作里若在 JSON body 也给了 `requestId`,其值**必须与 `X-Request-Id` 完全一致**,否则 400。账本/月账分页接口里的 `requestId` 是**过滤条件**(按业务号筛选),不是幂等号。
### 2.3 响应信封(`/api/v1/*` 与 `/internal/v1/*`)
```json
{ "success": true, "code": "OK", "message": null, "requestId": "biz-20260930-0001", "data": { } }
```
```json
{ "success": false, "code": "ACCOUNT_BLOCKED", "message": "账户不可用", "requestId": "biz-20260930-0001",
"data": { "reason": "INSUFFICIENT_BALANCE", "subAccountId": "100000000000000501" } }
```
- `/v1/*` 例外:成功与失败都是 **OpenAI 形态**(不经自有信封),见 §3.2。
- HTTP 状态码:`200` 处理/查询完成;`202` 已受理未定论(`code: "ACCEPTED"`);`4xx/5xx` 见 §2.4。
- 所有响应带 `Cache-Control: no-store`(`/v1` 与流式同样)。
### 2.4 错误码总表
| code | HTTP | 场景与处理建议 |
| --- | --- | --- |
| `INVALID_ARGUMENT` | 400 | 参数/格式/长度/白名单不符。**不要重试**,修参数 |
| `PAYLOAD_TOO_LARGE` | 413 | 请求体 > 1 MiB 或条数超限。修请求 |
| `AUTHENTICATION_FAILED` | 401 | 缺 Key 或摘要不符。修凭证 |
| `CREDENTIAL_PENDING` | 401 | Key 未激活(PENDING 24h 有效) |
| `CREDENTIAL_EXPIRED` | 401 | PENDING 超期或宽限期止 |
| `CREDENTIAL_REVOKED` | 401 | 已撤销(撤销后连幂等重放也不可用) |
| `ACCOUNT_ACCESS_REVOKED` | 403 | 该 client 对账户的授权被撤 |
| `ACCOUNT_DISABLED` | 403 | 账户停用 |
| `SUB_ACCOUNT_DISABLED` | 403 | 子账号停用:其下新调用拒绝,在途照常结算 |
| `SCOPE_DISABLED` | 403 | 门店/员工 scope 停用(含员工所属门店停用) |
| `PERMISSION_DENIED` | 403 | 角色越权;`data.reason` 如 `ACCOUNT_KEY_PLATFORM_ONLY` |
| `ACCOUNT_NOT_FOUND` | 404 | 账户不存在或不在你的可见范围 |
| `REQUEST_NOT_FOUND` | 404 | 查询的原请求/记录不存在 |
| `FINGERPRINT_MISMATCH` | 409 | 同一幂等号配了不同参数(见 §2.6) |
| `ACCOUNT_BLOCKED` | 409 | 准入阻断;`data.reason`:`INSUFFICIENT_BALANCE`(余额不足,可含 `subAccountId`)、`UNRESOLVED_CALL`、`ACCOUNT_NOT_READY`、`GIFT_GATE_BUSY`、`VERSION_CONFLICT`、`CALL_ALREADY_TERMINAL_OR_ACTIVE` |
| `SCOPE_CONFLICT` | 409 | `scopeKey` 已被占用(一店/一人一账号,全局唯一),需显式改派 |
| `GIFT_GATE_BUSY` | 409 | 账户赠送门闩被占用(有在途赠送) |
| `RATE_LIMITED` | 429 | 并发/容量上限,**可退避重试** |
| `EMPLOYEE_QUOTA_EXCEEDED` | 429 | 员工月度额度不足;`data` 带 `scopeId/quotaMonth/limitPointUnits/usedPointUnits`(子单位十进制字符串) |
| `DEPENDENCY_UNAVAILABLE` | 503 | 配置/DB/网关未就绪,**未登记新请求**,可重试 |
| `SERVICE_UNAVAILABLE` | 503 | 兜底;已登记请求用原 `requestId` 查询 |
**重试规则**:可重试 = `429` / `503` / `202 后查询`;`4xx` 其余一律不要重试。5xx **不得**一律当成"可换新号重发"。
### 2.5 状态字段语义(`call`)
| 字段 | 取值 | 含义 |
| --- | --- | --- |
| `executionStatus` | `PENDING/PROCESSING/SUCCEEDED/FAILED/UNKNOWN` | 模型执行 |
| `billingStatus` | `PROCESSING/SUCCESS/FAILED/SETTLE_PENDING/SETTLE_FAILED/UNKNOWN` | 账务 |
| `settled` | bool | 是否已入账(含零消费)。**只与 `billingStatus=SUCCESS` 同时成立** |
| `resultAvailable` | bool | 正文是否仍可取(正文保留 7 天,过期不影响账务终态) |
| `retryHint` | `NONE` / `QUERY_ORIGINAL_REQUEST` | 唯一安全动作 |
- `SUCCESS + settled=true`:已入账,终态。
- `FAILED + settled=false`:已证实未计费关闭,终态,两状态都不自动重发。
- 其余(含 `UNKNOWN`、`SETTLE_PENDING`、`SETTLE_FAILED`):未终结,**不能只把 `reconciled_time` 一设就放行**,需走核清接口(§4.10)。
### 2.6 幂等与指纹
三个幂等域:
| 域 | 键 | 适用 |
| --- | --- | --- |
| 调用 | `(accountId, clientId, requestId)` | `/api/v1/chat/completions`、`/v1/chat/completions` |
| 赠送 | `(accountId, clientId, requestId)` | `/internal/v1/gifts`(独立表,避免不同账户同号互相拦) |
| 管理 | `(clientId, operationType, requestId)` | 其余全部管理写操作 |
规则:
1. **同号同参 → 重放**:返回首次结果(已完成的给 200,未完成的 202),不重复扣费、不重复派发。重放**不做新准入检查**(余额变 0 也不影响重放)。
2. **同号异参 → 409 `FINGERPRINT_MISMATCH`**。指纹 = 规范化 JSON(键排序、紧凑分隔符、SHA-256)。
3. 凭证/授权失效**优先于**重放:撤销后的 Key 连重放都不可用。
4. 结果不明时**永远用原 `requestId` 重试**,不要换号。
指纹输入(对接方需要知道的边界):
| 操作 | 指纹输入 |
| --- | --- |
| 模型调用 | `{modelSelection, businessCode, businessRef, messages[{role,content}]}` + 流式选项 `include_usage`;**另追加凭证静态主体** `subject:{subAccountId, scopeId}` |
| 赠送 | `{account_id, points, operator_note}` |
| 开户 | `{name, remark, clientId}` |
| 签发 | `{accountId, clientId, subAccountId?, scopeId?}` |
| 建子账号 | `{accountId, name}` |
| 子账号停用 | `{subAccountId, status}` |
| 转账 | `{accountId, subAccountId, points, direction}` |
| 建门店/员工 | `{accountId, scopeType, scopeKey, subAccountId?, parentScopeKey?}` |
| scope 停用 | `{scopeId, status}` |
| 门店改派 | `{scopeId, subAccountId}` |
| 员工换店 | `{scopeId, parentScopeId}` |
| 设月额度 | `{scopeId, storeScopeId, monthlyQuotaPoints}` |
| 核清 | 除 `requestId`、`dryRun` 外全部字段 |
> 由此可得两条对接纪律:**同号换 Key 主体 = 异参**(用同号给不同员工签发会被 409);**同主体轮换 Key 不影响重放**。
### 2.7 权限矩阵
| 能力 | CALL Key | INTEGRATION Key | PLATFORM Key |
| --- | --- | --- | --- |
| 本账户余额/可用性/availability | 允许 | — | — |
| 发起调用、查询本来源调用与消费 | 允许(限本 account+client) | — | — |
| 跨来源账户账务摘要、账本/月账单/赠送查询 | 禁止 | 允许(限自有账户) | 允许 |
| 幂等开户 | 禁止 | 允许(owner=本 client) | 允许 |
| 签发/激活/撤销本来源调用 Key | 禁止 | 允许(限自有账户) | 允许 |
| **签发账户级 Key(无 subAccountId/scopeId)** | — | **禁止**(403 `PERMISSION_DENIED`,`reason=ACCOUNT_KEY_PLATFORM_ONLY`) | 允许 |
| 子账号/门店/员工/额度/转账 | 禁止 | 允许(限自有账户,目标必属本账户) | 允许(须显式指定目标) |
| 授权/撤销 client 接入、账户停启用 | 禁止 | 禁止 | 允许 |
| 赠送、人工核清 | 禁止 | 禁止 | 允许(且不得任意改余额,只能走赠送/消费事务) |
| 读取其他 client 的模型正文 | 禁止 | 禁止 | 禁止(仅运维只读归档) |
归属校验:INTEGRATION 只能操作 `ownerClientId = 自己` 的账户;PLATFORM 也必须显式给出目标账户(以及目标 `clientId`),否则 400。矩阵是代码常量,不做运行期配置。
### 2.8 精度、单位与时间
| 量 | JSON 表示 | 说明 |
| --- | --- | --- |
| 所有 id(accountId/subAccountId/scopeId/credentialId/callId/giftId) | **十进制字符串** | 18 位;历史导入可能更短 |
| 积分 `points` | 入参:JSON 整数(严格);出参:`xxxPoints` 为**固定 4 位小数字符串** | 例 `"5.3728"` |
| 子单位 `*PointUnits` | **十进制字符串** | `1 积分 = 10000 子单位` |
| `consumedQuota` | 十进制字符串 | 上游网关计量单位 |
| token 计数 | 十进制字符串 | |
| `page/size/total` | JSON number | |
| 时间 | ISO 8601 UTC(`Z`,毫秒) | 唯一例外:`quotaMonth`/`month` 为 `YYYY-MM`,按 **Asia/Shanghai** 自然月(月界 = 北京 0 点) |
换算(冻结常量):`子单位 = 积分 × 10000`;`消耗子单位 = max(quota,0) × 146`;(赠送)`quota = (子单位 + 73) // 146`,到账 `quota × 146`。
验证向量:1000 积分 → 68493 quota → 999.9978 积分;368 quota → 5.3728 积分。
### 2.9 分页约定
- 请求:`page`(≥1,默认 1)、`size`(1–200,默认 20)。
- 响应:`{ "total": <number>, "page": n, "size": n, "list": [ ... ] }`(月账单页为 `month` + `list`)。
- 排序稳定:调用/消费/赠送按 `createTime desc, id desc`;月账单按 `usedPointUnits desc, scopeId asc`。
- 越界页合法返回空 `list`(不会报错,也不会给驱动巨大 offset)。
- 凭证列表也已分页(`page`/`size`,`size` 上限 200),并支持 `status`/`subAccountId`/`scopeId` 筛选,见 §4.6;`size` 越界按 400 `INVALID_ARGUMENT` 处理。
---
## 3. 业务调用接口(CALL Key)
### 3.1 `POST /api/v1/chat/completions` — 原生模型调用(非流式)
鉴权:CALL Key。幂等域:`(accountId, clientId, requestId)`。
请求体:
| 字段 | 类型 | 必填 | 约束 |
| --- | --- | --- | --- |
| `businessCode` | string | 否 | ASCII 可打印 1–100;**必须在服务端白名单内**(当前 `CHAT`、`MEIJI_SKIN_ANALYSIS`),否则 400 |
| `businessRef` | string | 否 | ASCII 可打印 1–100,仅备注追踪,不参与鉴权与归属 |
| `model` | string | 否 | `^[A-Za-z0-9][A-Za-z0-9._:/+\-]{0,99}$`;**必须是服务端配置的模型**(当前单模型白名单),省略即用默认 |
| `messages` | array | 是 | 1–50 条;每条 `{role: system\|user\|assistant, content: string}`(`content` 1–1 048 576 字符,**本版本仅支持纯字符串,不支持多模态数组**) |
请求体上限 1 MiB(超出 413 `PAYLOAD_TOO_LARGE`)。
成功响应(200,已结算):
```json
{
"success": true, "code": "OK", "message": null, "requestId": "biz-20260930-0001",
"data": {
"accountId": "100000000000000001", "clientId": "mei1-saas",
"requestId": "biz-20260930-0001", "callId": "100000000000001234",
"executionStatus": "SUCCEEDED", "billingStatus": "SUCCESS", "settled": true,
"resultAvailable": true, "content": "……", "finishReason": "stop",
"model": "deepseek-v4.1-flash",
"usage": {"inputTokens": "210", "cacheHitInputTokens": "0", "outputTokens": "96", "totalTokens": "306"},
"consumedQuota": "368", "consumedPointUnits": "53728", "consumedPoints": "5.3728",
"consumptionRecordId": "100000000000005678", "retryHint": "NONE",
"errorCode": null, "errorMessage": null,
"createTime": "2026-09-30T02:11:03.120Z", "completeTime": "2026-09-30T02:11:07.480Z"
}
}
```
未定论响应(202):
```json
{
"success": true, "code": "ACCEPTED", "requestId": "biz-20260930-0001",
"data": {"callId": "100000000000001234", "executionStatus": "UNKNOWN", "billingStatus": "UNKNOWN",
"settled": false, "resultAvailable": false, "content": null,
"retryHint": "QUERY_ORIGINAL_REQUEST"}
}
```
出参字段完整清单见 §3.4 的“调用视图”。
### 3.2 `POST /v1/chat/completions` — OpenAI 兼容调用
鉴权:CALL Key。成功与失败均为 **OpenAI 形态**,不经自有信封。
- 请求兼容 OpenAI Chat Completions:`model`(可省,回退服务端配置)、`messages`(system/user/assistant;`developer` 映射为 system)。
- **采样参数白名单透传**:`temperature`、`top_p`、`max_tokens`、`max_completion_tokens`、`presence_penalty`、`frequency_penalty`、`seed`、`stop`、`stream`、`stream_options`。白名单外字段**忽略且不报错**(网关侧再做类型/边界校验)。
- `stream: true` 走 SSE(见下);`stream_options` 只允许 `{include_usage: bool}`,非流式请求不得带非 null 的 `stream_options`。
- 幂等:缺省 `X-Request-Id` 时服务端生成请求号(**每次调用独立,重试可能重复计费**,与直连一致)。建议客户端保留业务请求号、关闭自动重试。自带请求号时同号同参重放返回同一 `callId`;切换流式/非流式或改变 `include_usage` 视为异参 → 409 `FINGERPRINT_MISMATCH`。
非流式成功响应:
```json
{
"id": "chatcmpl-biz-20260930-0001", "object": "chat.completion", "created": 1759198263,
"model": "deepseek-v4.1-flash",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "……"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 210, "completion_tokens": 96, "total_tokens": 306},
"mei1": {"requestId": "biz-20260930-0001", "callId": "100000000000001234",
"billingStatus": "SUCCESS", "consumedPoints": "5.3728"}
}
```
`mei1` 为扩展字段(OpenAI 客户端会忽略),用于对账:**请务必记录 `requestId` 与 `callId`**。
错误响应:
```json
{"error": {"message": "账户余额不足: INSUFFICIENT_BALANCE", "type": "insufficient_quota",
"code": "ACCOUNT_BLOCKED", "param": null,
"mei1": {"reason": "INSUFFICIENT_BALANCE", "subAccountId": "100000000000000501"}}}
```
映射规则:`EMPLOYEE_QUOTA_EXCEEDED` → 429 `insufficient_quota`;余额不足(`ACCOUNT_BLOCKED` + `reason=INSUFFICIENT_BALANCE`)→ 429 `insufficient_quota`;`RATE_LIMITED` → 429 `rate_limit_error`;执行未产生正文 → 500/503 `api_error`(`mei1` 保留 `callId`/`requestId`/`executionStatus` 供查询);其余 4xx → `invalid_request_error`。
**SSE 流式(`stream: true`)**
- 响应 `Content-Type: text/event-stream`,事件形如 `data: {"id":"chatcmpl-<requestId>","object":"chat.completion.chunk",...,"choices":[{"index":0,"delta":{...},"finish_reason":null}]}`;`include_usage=true` 时尾部追加 `choices: []` 的 usage chunk,最后 `data: [DONE]`。
- 增量只转发 `role`/`content`/`reasoning_content`;正文持久化,`reasoning_content` 仅实时转发不保存。`finish_reason` 限 `stop/length/content_filter`。
- 响应头:`Cache-Control: no-store`、`X-Accel-Buffering: no`、`X-Request-Id`,**无 Content-Length**。反向代理必须关闭响应缓冲,读取超时覆盖 150 秒。
- 首个事件前的失败仍返回 OpenAI JSON 错误;已发响应头后失败改为 `data: {"error": ...}` 并结束(**不发 `[DONE]`**)。
- `[DONE]` **只表示生成成功**,不保证 `billingStatus=SUCCESS`;账务请用 `GET /api/v1/calls/{requestId}` 复查。
- 原执行仍为 `PENDING/PROCESSING` 时的同号重放返回 JSON 409(`reason=ORIGINAL_REQUEST_PENDING`);正文已清理或执行未成功时不重新派发。
### 3.3 `GET /v1/models`
CALL Key。返回单模型列表(服务端配置的模型),仅供 SDK 探活/枚举:
```json
{"object": "list", "data": [{"id": "deepseek-v4.1-flash", "object": "model", "created": 0, "owned_by": "mei1"}]}
```
### 3.4 `GET /api/v1/calls/{requestId}` — 查询原请求
CALL Key。`requestId` 作为路径参数须 **URL 编码**。这是**唯一**的“结果不明”处置手段。
返回“调用视图”:
| 字段 | 说明 |
| --- | --- |
| `accountId` / `clientId` / `requestId` / `callId` | 主键组 |
| `executionStatus` / `billingStatus` / `settled` / `dispatchPhase` | 状态(§2.5) |
| `model` / `businessCode` / `businessRef` | 快照 |
| `usage.{inputTokens,cacheHitInputTokens,outputTokens,totalTokens}` | token(字符串) |
| `consumedQuota` / `consumedPointUnits` / `consumedPoints` | 费用(未结算为 `null`) |
| `consumptionRecordId` | 消费记录 id |
| `retryHint` / `errorCode` / `errorMessage` | 处置建议与错误 |
| `resultAvailable` / `content` / `finishReason` | 正文(7 天内可取) |
| `createTime` / `completeTime` | 时间 |
错误:`REQUEST_NOT_FOUND`(404,不存在或不属于本 Key 的账户/来源)。
### 3.5 `GET /api/v1/account` — 本账户概览
CALL Key。
```json
{"data": {"accountId": "100000000000000001", "name": "某某美容院", "remark": null,
"ownerClientId": "mei1-saas", "status": "ACTIVE", "provisionStatus": "READY",
"balancePointUnits": "9896172", "balancePoints": "989.6172",
"available": true, "blockedReasons": [],
"bucketType": "ACCOUNT", "bucketId": null, "subAccountId": null, "scopeId": null,
"unallocatedPointUnits": "9896172",
"subAccounts": [{"subAccountId": "100000000000000501", "name": "华东预算",
"status": "ACTIVE", "balancePointUnits": "5000000", "balancePoints": "500.0000"}]}}
```
判定口径(2026-09-30 修正):本接口与调用准入**共用同一套主体规则**,返回的是"**这把 Key** 现在能不能调用、扣的是哪个桶"。
- `balancePointUnits`:**该 Key 实际扣费的那个桶**的余额(`balancePoints` 是同一值的 4 位小数展示),可负(历史数据)。账户级 Key = 账户未分配桶;子账号 Key = 该子账号桶;门店/员工 Key = 门店归属的子账号桶(门店直挂账户时为账户未分配桶)。
- `bucketType`(`ACCOUNT`|`SUB_ACCOUNT`) 与 `bucketId`(桶 ID,`ACCOUNT` 桶为 `null`)明确"钱从哪扣";`subAccountId`/`scopeId` 是**这把 Key 自己绑定的主体**(未绑定为 `null`)。
- `unallocatedPointUnits` 恒为账户未分配桶(与 `subAccounts[]` 一起看层级);账户级 Key 时它与 `balancePointUnits` 相等。
- `available=false` 时 `blockedReasons` 给原因:`ACCOUNT_DISABLED`、`ACCOUNT_NOT_READY`、`DEPENDENCY_UNAVAILABLE`(网关绑定/计量口径异常)、`GIFT_GATE_BUSY`(有在途赠送)、`UNRESOLVED_CALL`(有未核清调用)、`INSUFFICIENT_BALANCE`(扣费桶余额 ≤ 0)、`SUB_ACCOUNT_DISABLED`、`SUB_ACCOUNT_NOT_FOUND`、`SCOPE_DISABLED`(门店/员工停用;门店停用连带其员工)、`EMPLOYEE_QUOTA_EXCEEDED`(员工当月额度用尽)。
- `available` 是**查询时点**的结论,不预占、不预扣;并发竞争仍以调用瞬间为准(调用返回 409/429 时以调用结果为准)。
- 非 CALL Key 调用返回 403 `PERMISSION_DENIED`。
### 3.6 `POST /api/v1/account/availability` — 仅查询可用性
CALL Key;**请求体必须为空对象 `{}`**(非空则 400)。响应与 §3.5 同构(不含层级明细)。适合高频探活。
### 3.7 `POST /api/v1/consumptions/page` — 本来源消费分页
CALL Key。请求体(`LedgerPageRequest`):`page`、`size`、`start`、`end`(ISO 时间)、`requestId`(按业务号过滤)。
**CALL Key 不得传** `accountIds`/`ownerClientId`/`clientId`/`subAccountIds`/`scopeIds`/`storeScopeIds`(传了即 403 `PERMISSION_DENIED`——身份与分账维度只能由 Key 决定)。
响应:`{total, page, size, list:[消费视图]}`。消费视图字段见 §4.12。
---
## 4. 管理接口(`/internal/v1`,INTEGRATION / PLATFORM Key)
### 4.1 管理写操作的统一响应
除赠送与核清外,所有管理写操作返回统一结构:
```json
{"success": true, "code": "OK", "requestId": "issue-store-key-01",
"data": {"requestId": "issue-store-key-01", "operationType": "ISSUE_CALL_KEY", "operationStatus": "SUCCEEDED",
"targetId": "100000000000002001", "errorCode": null, "...": "该操作特有字段"}}
```
`operationStatus`:`SUCCEEDED`(HTTP 200)/ `PENDING`、`NEEDS_RECONCILIATION`(HTTP 202)。
### 4.2 `POST /internal/v1/accounts` — 幂等开户
鉴权:INTEGRATION(owner 固定为自己)/ PLATFORM。幂等域:`(clientId, PROVISION_ACCOUNT, requestId)`,指纹 `{name, remark, clientId}`。
| 字段 | 类型 | 必填 |
| --- | --- | --- |
| `name` | string 1–100 | 是 |
| `remark` | string ≤500 | 否 |
| `clientId` | `^[a-z0-9-]{2,50}$` | 否(INTEGRATION 只能用自己) |
响应:`{accountId, provisionStatus, ...}`。重复 request 返回首次结果。开户是异步供给(绑定网关令牌),`provisionStatus` 为 `PENDING/READY/FAILED/NEEDS_RECONCILIATION`。
### 4.3 `POST /internal/v1/accounts/query` — 账户分页
鉴权:管理 Key(**只读接口,不需要 `X-Request-Id`**)。请求:`{accountIds?: [id], page, size}`。
响应 `{total, list:[账户视图], page, size}`;账户视图 = §3.5 字段(不含层级明细)。INTEGRATION 只能看到 `ownerClientId=自己` **且** `account_client.status=AUTHORIZED` 的账户;PLATFORM 不带 `accountIds` 则返回全部。
### 4.4 `POST /internal/v1/accounts/{accountId}/status` — 账户停启用
**仅 PLATFORM**。请求:`{status: ACTIVE|DISABLED, reason(1–200)}`。停用后新调用一律拒绝(403 `ACCOUNT_DISABLED`),在途照常结算。
### 4.5 `POST /internal/v1/accounts/{accountId}/clients` — 授权/撤销接入
**仅 PLATFORM**。请求:`{clientId, status: AUTHORIZED|REVOKED, reason(1–200)}`。授权是“接入权”,不是商户映射。撤销后该 client 对该账户的调用返回 403 `ACCOUNT_ACCESS_REVOKED`,且**优先于幂等重放**。
### 4.6 凭证签发与查询
**签发** `POST /internal/v1/accounts/{accountId}/credentials`(INTEGRATION 限自有账户 / PLATFORM)
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `clientId` | `^[a-z0-9-]{2,50}$` | 否 | 归属来源;INTEGRATION 只能用自己 |
| `subAccountId` | id | 否 | 与 `scopeId` **互斥**,必须属于该账户 |
| `scopeId` | id | 否 | 门店或员工 scope;必须属于该账户 |
- 四类 Key 由这两个字段决定(§2.1);**账户级(两者都空)仅 PLATFORM 可签**,商户自签返回 403 `PERMISSION_DENIED`(`reason=ACCOUNT_KEY_PLATFORM_ONLY`)。
- 响应:`{credentialId, secret, secretAvailable: true, scopeId?, subAccountId?, scopeType?, ...}`——**`secret` 只出现这一次**,此后任何接口都不再返回(`credential_view` 永远 `secretAvailable:false`,只给 `secretMask` 前后 4 位)。请即刻写入受控存储。
- 新 Key 初始为 `PENDING`(24 小时有效),须调用激活接口后才能用于调用。
**查询** `GET /internal/v1/accounts/{accountId}/credentials`(管理 Key)
查询参数:`clientId?`(来源;PLATFORM 必填)、`status?`(`PENDING|ACTIVE|REVOKED`)、`subAccountId?`、`scopeId?`、`page`(≥1,默认 1)、`size`(1–200,默认 20)。
响应 `{total, page, size, list:[凭证视图]}`,凭证视图字段:`credentialId`、`clientId`、`accountId`、`category(CALL|INTEGRATION|PLATFORM)`、`status(PENDING|ACTIVE|REVOKED)`、`secretMask`、**`subAccountId`**、**`scopeId`**、**`scopeType`(`STORE|EMPLOYEE`,未绑定为 null)**、`pendingExpiresAt`、`validUntil`、`activatedAt`、`replacedBy`、`secretAvailable`。
- 排序稳定:`createTime desc, id desc`;已撤销凭证保留并计入 `total`(历史审计用途),需要时用 `status=REVOKED` 筛出。
- 绑定主体字段在**签发、激活、撤销、列表、重放**五处响应中一致,且都不会再次返回 `secret`。
- "每门店/员工一把 Key" + 轮换会持续增加凭证数量,请按页盘点而不是一次拉全量。
### 4.7 `POST /internal/v1/credentials/{credentialId}/activate` — 激活(含轮换)
请求:`{clientId?, replacesCredentialId?}`。
- 激活后 `status=ACTIVE`;若带 `replacesCredentialId`(同一 client/账户、当前 ACTIVE、未被替代),则旧 Key 进入宽限期:`validUntil = min(原值, now+24h)`,用于平滑轮换。
- 响应:凭证视图 + `credentialId`。
### 4.8 `POST /internal/v1/credentials/{credentialId}/revoke` — 撤销
请求:`{reason(1–200), clientId?}`。撤销后该 Key 立即不可用(401 `CREDENTIAL_REVOKED`),**连幂等重放也失败**。
### 4.9 子账号
| 操作 | 路径 | 说明 |
| --- | --- | --- |
| 建子账号 | `POST /internal/v1/accounts/{accountId}/sub-accounts` | `{name(1–100), remark?}`;`UNIQUE(accountId, name)`;响应 `{subAccountId, status, balancePointUnits}`;重放返回同一 `subAccountId` |
| 查列表 | `GET /internal/v1/accounts/{accountId}/sub-accounts?page&size` | 响应 `{total, list:[{subAccountId, accountId, name, remark, status, balancePointUnits, balancePoints, createTime}], page, size}` |
| 停启用 | `POST .../sub-accounts/{subAccountId}/status` | `{status: ACTIVE\|DISABLED, reason}`;停用后其下新调用 403 `SUB_ACCOUNT_DISABLED`,**在途照常结算,余额不自动回流** |
| 余额划拨 | `POST .../sub-accounts/{subAccountId}/transfers` | `{points(1–10^9), direction: IN\|OUT, operatorNote?}`;**OUT = 账户→子账号**,IN = 子账号→账户 |
划拨语义(重要):**分配即冻结**——账户桶条件扣减、子账号桶条件累加,任一侧不足**整单拒绝**(409 `ACCOUNT_BLOCKED` + `reason=INSUFFICIENT_BALANCE`,**无部分转账**)。响应 `{subAccountId, direction, points, balancePointUnits}`。转账是纯本地单事务、无门闩;结果不明时用原 `requestId` 回查(`GET /internal/v1/requests/TRANSFER/{requestId}`)。子账号之间不直接互转(先回流再分配)。两笔流水 `TRANSFER_OUT/TRANSFER_IN` 同事务、金额相等、方向相反。
### 4.10 门店与员工(scope)
`scopeKey` 是外部系统的 opaque 标识:`^[a-z0-9-]{1,30}:[A-Za-z0-9._:-]{1,69}$`(总长 ≤100,前缀代表来源系统 + 实体标识),本服务不解析它;同一实体必须固定由同一来源系统上报。`UNIQUE(scopeType, scopeKey)` 全局唯一 → **一个门店只能属于一个账户**,撞号返回 409 `SCOPE_CONFLICT`(需显式改派)。
| 操作 | 路径 | 请求与语义 |
| --- | --- | --- |
| 建门店/员工 | `POST /internal/v1/accounts/{accountId}/scopes` | `{scopeType: STORE\|EMPLOYEE, scopeKey, subAccountId?, parentScopeKey?}`;**门店**可带 `subAccountId`(省略 = 直挂账户,共用账户未分配余额);**员工必须带 `parentScopeKey`**(同账户、状态 ACTIVE 的门店),继承门店的归属。响应 `{...scope视图}` |
| 查列表 | `GET /internal/v1/accounts/{accountId}/scopes?page&size&scopeType=&subAccountId=` | `{total, list:[scope视图], page, size}`;scope 视图 = `{scopeId, accountId, scopeType, scopeKey, status, subAccountId, parentScopeId, createTime}` |
| 停启用 | `POST .../scopes/{scopeId}/status` | `{status, reason}`;停用后(含员工的门店被停用)新调用 403 `SCOPE_DISABLED`,在途照常结算,额度配置行保留 |
| 门店改派 | `POST .../scopes/{scopeId}/sub-account` | `{subAccountId: id\|null}`;门店在子账号之间/直挂账户之间双向切换,**不移动任何余额** |
| 员工换店 | `POST .../scopes/{scopeId}/assignment` | `{parentScopeId}`(目标门店必属同账户);**当月已用跨店累计不清零**,生效额度按新门店配置解析 |
| 设月额度 | `POST .../scopes/{scopeId}/quota` | `{storeScopeId, monthlyQuotaPoints: 整数\|null}`;见 §5.3 |
### 4.11 赠送(PLATFORM)
**创建** `POST /internal/v1/gifts`
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `accountId` | id | 是 | 目标账户 |
| `clientId` | `^[a-z0-9-]{2,50}$` | 是 | 目标来源(须对该账户 AUTHORIZED) |
| `points` | 整数 1–10^9 | 是 | 积分 |
| `operatorNote` | ≤200 | 否 | 备注 |
| `exclusiveWriterConfirmed` | bool | 是 | 必须为 `true`,确认“该账户的网关令牌由此赠送独占写入” |
| `exclusivityEvidenceRef` | ASCII 1–200 | 是 | 独占性证据引用(工单号等) |
响应为**赠送视图**:`{giftId, accountId, clientId, requestId, status(PROCESSING|SUCCESS|FAILED|NEEDS_RECONCILIATION), phase, version, requestedPointUnits, quotaDelta, creditedPointUnits, creditedPoints, balanceBeforeUnits, balanceAfterUnits, operatorNote, errorCode, retryHint, createTime, lastUpdateTime}`;失败清理阶段(`phase=FAILED_CLEAR` 且网关目标额度为负)会附 `minimumPoints`(本次实际可用的最小积分,需按此重发)。
语义:只有 `phase=CONFIRMED`(`status=SUCCESS`)算完成;`NEEDS_RECONCILIATION` 或 `FAILED` **不清不重发**,须核清;账户赠送门闩(`gift_gate`)占用至终态或核清,期间其他赠送返回 409 `GIFT_GATE_BUSY`。
**查询**:`POST /internal/v1/gifts/page`(`{accountIds?, ownerClientId?, requestId?, start?, end?, page, size}`,只读接口无需 `X-Request-Id`);`GET /internal/v1/gifts/{giftId}?accountId=&clientId=`(两个查询参数必填)。
**核清** `POST /internal/v1/gifts/{giftId}/reconcile`(**仅 PLATFORM**):
| 字段 | 说明 |
| --- | --- |
| `accountId` / `clientId` | 目标校验,不符返回 404 |
| `action` | `CONFIRM_APPLIED`(确认已生效)/ `CLOSE_NOT_APPLIED`(确认未生效并关闭)/ `RETRY_UNSENT`(确认未发出,重发) |
| `expectedVersion` | 乐观锁;与当前 `version` 不符 → 409 `ACCOUNT_BLOCKED`(`reason=VERSION_CONFLICT`) |
| `reason`(1–500)/ `evidenceRef`(ASCII 1–200) | 审计必填 |
| `writersDrained` | 必须为 `true`:确认所有写方已停止 |
| `dryRun` | 默认 `true` = 只预演不落库(此时即使待决也返回 200) |
### 4.12 账本:消费分页与月度账单
**消费分页** `POST /internal/v1/consumptions/page`(管理 Key;只读接口)
请求(`LedgerPageRequest`):`accountIds?`(1–1000)、`ownerClientId?`、`clientId?`、`requestId?`、`start?`、`end?`、`subAccountIds?`、`scopeIds?`、`storeScopeIds?`(各 1–1000)、`page`、`size`。
- **分账维度口径**:`subAccountIds` 过滤的是**扣费桶**——含直挂该桶的调用**以及该桶下门店与员工的消费**(门店/员工调用的快照 `subAccountId` 就是它所扣的桶);`storeScopeIds` = 门店维度(该门店自身 + 其下员工);`scopeIds` = 员工维度(跨门店连续)。
- **账户桶(未分配余额)无法用参数直接筛**:`subAccountId = null` 不对应任何 ID。要算账户桶的账,用「账户全部消费(不传维度)− Σ 各子账号桶(`subAccountIds`)」。
- 三维度只在**账户范围内**生效;跨账户的 ID 会得到空结果(不是 403)。
- PLATFORM 必须给 `ownerClientId` 或 `accountIds`,否则 400。
响应 `{total, page, size, list:[消费视图]}`;消费视图字段:
| 字段 | 说明 |
| --- | --- |
| `id` / `consumptionRecordId` / `callId` | 记录标识(`callId` 对于历史导入行为 null,此时 `legacyRecord=true`) |
| `accountId` / `clientId` / `requestId` | 归属 |
| `businessCode` / `businessRef` / `model` / `source` / `legacyRecord` | 业务与来源 |
| `subAccountId` / `scopeId` / `storeScopeId` | 分账维度(多为 null;非 null 时即门店/员工/子账号归属) |
| `dispatchPhase` / `executionStatus` / `billingStatus` / `settled` / `settlementSource` | 状态与结算来源(`GATEWAY_LOG`/`MANUAL`/`LEGACY`) |
| `inputTokens` / `cacheHitInputTokens` / `outputTokens` / `totalTokens` | token |
| `consumedQuota` / `pointUnitsPerQuota` / `consumedPointUnits` / `consumedPoints` | 费用与换算快照 |
| `createTime` / `lastUpdateTime` / `completeTime` / `settledTime` | 时间 |
**月度账单** `POST /internal/v1/consumptions/months`(管理 Key;只读接口)
请求(`ScopeMonthPageRequest`):`{accountIds?, ownerClientId?, month?, subAccountIds?, scopeIds?, storeScopeIds?, page, size}`;`month` 为 `YYYY-MM`(缺省 = 当前月,按 Asia/Shanghai;非法如 `2026-13` → 400)。
响应:
```json
{"data": {"total": 2, "page": 1, "size": 20, "month": "2026-09",
"list": [{"scopeId": "100000000000000901", "scopeType": "EMPLOYEE", "scopeKey": "mei1-saas:emp-77",
"storeScopeId": "100000000000000900", "storeScopeKey": "mei1-saas:store-3",
"subAccountId": "100000000000000501", "month": "2026-09",
"usedPointUnits": "107456", "usedPoints": "10.7456",
"limitPointUnits": "10000000", "limitPoints": "1000.0000", "unlimited": false,
"scopeStatus": "ACTIVE", "storeStatus": "ACTIVE"}]}}
```
- `usedPointUnits` 直读月用量状态(员工维度),`total` 为行数(每行一个员工×月)。
- **只有当月**才返回 `limitPointUnits/limitPoints/unlimited`;**历史月这三项为 null**(历史生效额度只能靠审计重放,接口不猜)。
- PLATFORM 读取会写入只读审计(`QUERY_MONTH_USAGE`)。
### 4.13 `POST /internal/v1/calls/{callId}/reconcile` — 调用人工核清(PLATFORM)
用于处置结果不明/结算失败的调用(§2.5)。请求:
| 字段 | 说明 |
| --- | --- |
| `accountId` / `clientId` | 目标校验,不符 404 |
| `action` | `BIND_GATEWAY_LOG`(关联网关日志结算)/ `CLOSE_UNBILLED`(确认未计费关闭)/ `SETTLE_MANUAL`(人工给定用量结算) |
| `expectedVersion` | 乐观锁(0–2147483646),不符 → 409 `ACCOUNT_BLOCKED`(`reason=VERSION_CONFLICT`) |
| `reason`(1–500)/ `evidenceRef`(ASCII 1–200) | 审计必填 |
| `writersDrained` | 必须 `true` |
| `dryRun` | 默认 `true`(预演) |
| `gatewayRequestId` | `BIND_GATEWAY_LOG` 用:网关日志关联号 |
| `consumedQuota` | `SETTLE_MANUAL` 用:十进制非负字符串 |
前置校验:调用必须处于 `UNKNOWN`/`SETTLE_FAILED` 且未结算,否则 409 `ACCOUNT_BLOCKED`(`reason=CALL_ALREADY_TERMINAL_OR_ACTIVE`)。响应为调用视图(不含正文)+ `dryRun`。
### 4.14 `GET /internal/v1/requests/{operationType}/{requestId}` — 幂等回查
管理 Key。这是**所有管理操作“结果不明”的统一处置手段**:传原 `requestId`,返回该操作的持久化结果。
`operationType` 取值:`PROVISION_ACCOUNT`、`ISSUE_CALL_KEY`、`ACTIVATE_CREDENTIAL`、`REVOKE_CREDENTIAL`、`GRANT_CLIENT`、`REVOKE_CLIENT`、`SET_ACCOUNT_STATUS`、`RECONCILE`、`CREATE_SUB_ACCOUNT`、`SET_SUB_ACCOUNT_STATUS`、`TRANSFER`、`BIND_SCOPE`、`SET_SCOPE_STATUS`、`MOVE_STORE`、`MOVE_EMPLOYEE`、`SET_SCOPE_QUOTA`(其余 → 400;不存在 → 404 `REQUEST_NOT_FOUND`)。
响应 = §4.1 的统一结构;操作类型为 `GRANT_CLIENT`/`REVOKE_CLIENT`/`SET_ACCOUNT_STATUS`/`RECONCILE` 时**仅 PLATFORM** 可查,其余可由对应 INTEGRATION 查询自有目标。
---
## 5. 账户层级与限额模型(对接必读)
### 5.1 层级与主体
```
账户 accountId(一商户一账户,余额桶 = 未分配余额)
├── 子账号 subAccountId(N 个,独立余额桶;分配即冻结)
│ └── 门店 scopeType=STORE(N 个;可改派到别的子账号)
│ └── 员工 scopeType=EMPLOYEE(N 个;必属一个门店)
└──(直挂账户的门店,subAccountId = null,共用账户未分配余额)
```
- 一个门店**只能在一个账户下**(`scopeKey` 全局唯一硬保证)。
- 员工必属于门店;员工换店后**已用额度不清零**(跨店累计)。
- 调用主体只有四种:账户 / 子账号 / 门店 / 员工,全部由 Key 决定。
### 5.2 余额(硬)与额度(软)的区别
| | 余额 | 员工月度额度 |
| --- | --- | --- |
| 谁受限 | 所有主体 | **仅员工级 Key** |
| 约束对象 | 账户未分配余额 / 子账号余额 | 该员工当月在当前门店的用量 |
| 判定时点 | 登记时准入 + 结算时扣减 | **仅登记时**只读比对 |
| 周期 | 无(累计) | 自然月(Asia/Shanghai),无定时重置任务,新月首条从 0 起 |
| 超额后果 | 准入拒绝 409 `ACCOUNT_BLOCKED`(`reason=INSUFFICIENT_BALANCE`) | 准入拒绝 429 `EMPLOYEE_QUOTA_EXCEEDED` |
| 是否预扣 | 否 | 否 |
**门店不设限额**:门店的消耗上限就是它所属子账号的余额(直挂时是账户未分配余额)。
**“软限额”的准确含义**:额度只在登记时比对,不预留不预扣。因此**并发或已派发在途的调用可能小幅越限**,越限窗口 ≈ 在途调用预算(150 秒)+ 结算重试退避(最长 ≈21.35 小时)。服务端在跨越限额那一刻写结构化告警(`logger.warning`,字段含 `scopeId/storeScopeId/quotaMonth/limitPointUnits/usedPointUnits`),当前**无指标出口**(见 §7.5),需要主动采集日志。
### 5.3 月度额度的生效规则(关键)
对某个员工在**当前所属门店**上:
1. 该 (员工, 门店) 有配置行 → 用配置值:`monthlyQuotaPoints = 0` 表示**零额度**(拒绝一切新调用),`null` 表示**显式不限**。
2. 该 (员工, 门店) **没有**配置行 → **不限**(不会回退取该员工在其他门店的配置值)。
3. 月中调整**立即生效**;允许调到**低于当月已用**(此时拒新调用直到上调或次月),这时会留一条结构化告警。
4. 换店时:已用量跨店累计;额度只按**新门店**的配置立即生效。
> ⚠ **强约束的正确用法**:由于“新门店无配置 = 不限”,如果先给员工配低额度、再把他换到未配置的新门店,就绕过了限额。**要求硬约束时,必须为每个门店都配置额度**(含“0”这种显式零额度)。这是已知设计取舍(有告警、无硬拦截)。
### 5.4 停用的连带规则
| 停用对象 | 新调用 | 在途调用 | 余额 |
| --- | --- | --- | --- |
| 账户 | 403 `ACCOUNT_DISABLED` | 照常结算 | 保留 |
| 子账号 | 403 `SUB_ACCOUNT_DISABLED`(其下门店/员工全部受影响) | 照常结算 | **不自动回流**,需手动 `IN` 转账 |
| 门店 | 403 `SCOPE_DISABLED` | 照常结算 | 保留 |
| 员工(或其门店) | 403 `SCOPE_DISABLED` | 照常结算 | 保留 |
---
## 6. 记账、扣费与对账口径
### 6.1 一次调用的时间线
```
① 登记 校验 Key → 校验准入(账户/子账号/门店状态、余额、员工额度)→ 落 call 行(含主体快照 + quotaMonth)
—— 同 requestId 同参直接返回历史结果(重放),不重新派发
② 派发 调上游(NewAPI 或直连);派发阶段 dispatchPhase=REGISTERED→DISPATCHING→RESPONDED
③ 结果 executionStatus=SUCCEEDED/FAILED/UNKNOWN;正文写 result_json(保留 7 天)
④ 结算 以网关日志 quota 为准:consumedPointUnits = max(quota,0) × 146
同一事务写 consumption + point_record(按主体快照扣对应桶)+ 更新余额;settled=true / billingStatus=SUCCESS
—— 日志延迟 → SETTLE_PENDING,交给补偿重试(退避 1/5/15/60 分钟,最多 24 次,≈21.35 小时)
```
- **金额未确定时为 `null`**,不是 0;`consumedPointUnits` 为 null 表示尚未结算。
- 结算证据来源 `settlementSource`:`GATEWAY_LOG`(正常)/ `MANUAL`(人工核清)/ `LEGACY`(历史导入)。
- 网关换算比例必须等于 500000 quota/单位(`point_units_per_quota` 也会作为快照记录在消费行里);比例不符时服务拒新账务并告警。
### 6.2 分账口径(门店账 / 员工账 / 子账号账)
| 想要的账 | 传参 | 口径 |
| --- | --- | --- |
| 门店账 | `storeScopeIds` | 该门店自身 + 其下所有员工的消费 |
| 员工账 | `scopeIds` | 该员工跨门店的当月/历史消费(换店也连续) |
| 子账号账(桶账) | `subAccountIds` | **该桶扣费的全部消费**:直挂该桶的调用 + 该桶下门店与其员工的消费 |
| 账户桶账 | 无直接参数 | 账户全部消费(不传维度)− Σ 各子账号桶(`subAccountIds`);`subAccountId=null` 不等于任何 ID |
要点:账户级 Key 与**直挂账户的门店/员工**产生的消费不带子账号维度(`subAccountId=null`,即账户桶);门店的 `storeScopeId` 恒等于其自身(门店 Key)或当前门店(员工 Key),因此**一个门店下所有主体(门店本身 + 员工)都能用 `storeScopeIds` 一把捞出**。余额流水在 `point_record` 分桶(对接方无直接查询接口,请用 `GET /api/v1/account` 的 `subAccounts[].balancePointUnits` 与账本接口组合核对)。
### 6.3 对账恒等式(外部可自校验的 4 条)
1. **消费 = 分账聚合**:某桶的总消费 = 该桶全部 `SUCCESS` 消费行 `consumedPointUnits` 之和。
2. **月用量 = 消费聚合**:`consumptions/months` 的 `usedPointUnits` = 该员工该月全部 `SUCCESS` 消费之和(按**登记月**快照归类,不是结算月)。
3. **金额闭环**:`consumedPointUnits = max(consumedQuota,0) × pointUnitsPerQuota(=146)`;`consumedPoints = consumedPointUnits / 10000`(固定 4 位)。
4. **转账守恒**:`TRANSFER_OUT/IN` 同 requestId 成对、同事务、金额相等方向相反;所有桶余额之和 = 账户总余额。
**出账时点**:月度账单在**次月 2 日(北京)后**视为已出账(当月接口可实时查,历史月只给用量、不给额度)。
---
## 7. 对接注意事项
### 7.1 必须遵守的三条纪律
1. **重试必须用原 `requestId`**:任何“超时/结果不明”都只能用原请求号重查(`GET /api/v1/calls/{requestId}` 或 `/internal/v1/requests/{op}/{requestId}`),**不要换号**。换号 = 可能重复扣费、可能重复建门店/签发 Key。
2. **`requestId` 长度 ≥ 8**:`^[!-~]{8,100}$`,不要用 `req-1` 这类短号。
3. **关闭 HTTP 客户端的自动重试**,由业务层按上述规则决定是否重试;SSE 场景尤其如此。
### 7.2 尚未支持(本版本范围外)
| 项 | 现状 |
| --- | --- |
| 多模态(图片/文件) | `content` 仅接受字符串;`messages[].content` 数组形式不支持 |
| 工具调用 / function calling | 不支持;白名单外字段被忽略 |
| 多模型 | 服务端**单模型白名单**(配置项 `newapi_model`);请求 `model` 必须等于它 |
| 业务码白名单 | 仅 `CHAT`、`MEIJI_SKIN_ANALYSIS`,新增需改代码常量 |
| 子账号互转 | 不支持(先 `IN` 回流账户,再 `OUT` 分配) |
| 跨商户迁移门店 | 不支持(换 `scopeKey` 重建) |
| 历史月的生效额度 | 接口不返回(历史月额度需审计重放) |
### 7.3 无限额约束的账户级方案(给不带层级的接入方)
如果接入方暂时不想用门店/员工层级:**账户级 Key 需平台签发**(商户自签会 403)。两种可行路径:
1. 请平台为你的账户签发一个账户级 Key(测试/直连场景);
2. 用**门店级 Key**:建一个“虚拟门店”并把它的 `subAccountId` 留空(直挂账户),即可复用账户未分配余额,且不引入月度额度。
### 7.4 安全约定
- `secret` 只在签发响应里出现**一次**;服务端只存摘要,永久无法再次取出。请写入受控存储(文件权限 0600 之类)。
- 除签发外**任何接口都不返回 secret**;其它凭证查询只给 `secretMask`。
- 模型正文(`content`)保留 **7 天**(`resultAvailable` 变 false 属正常,不影响账务终态)。
- **禁止把 Key 放进前端/浏览器**:CALL Key 应只存在于后端;管理 Key 只存在于后端与运维。
### 7.5 上线前需平台侧确认的配置项
| 项 | 状态 |
| --- | --- |
| 目标环境地址(base_url) | 待落地;Java 侧 `computing.service.baseUrl` 尚未配置 |
| 目标库 | MySQL ≥ 8.0.16(CHECK 约束)、READ COMMITTED、UTC、严格模式 |
| 管理 Key 权限拆分 | 尚未拆分(Java 侧当前为单一 `managementKey`);建议按“开户/签发”与“赠送/核清”拆成两个 Key 分别下发 |
| 指标/告警出口 | 无(仅结构化 JSON-ish 日志行);越限告警需自采 |
| 反向代理 | SSE 必须关闭响应缓冲(`X-Accel-Buffering: no`),读取超时 ≥150 秒 |
---
## 附录 A:枚举值全表
| 字段 | 允许值 |
| --- | --- |
| `credential.category` | `CALL`、`INTEGRATION`、`PLATFORM` |
| `credential.status` | `PENDING`、`ACTIVE`、`REVOKED` |
| `account.status` | `ACTIVE`、`DISABLED` |
| `account.provisionStatus` | `PENDING`、`READY`、`FAILED`、`NEEDS_RECONCILIATION` |
| `subAccount.status` / `scope.status` | `ACTIVE`、`DISABLED` |
| `scope.scopeType` | `STORE`、`EMPLOYEE` |
| `account_client.status` | `AUTHORIZED`、`REVOKED` |
| `transfer.direction` | `IN`(子→账户)、`OUT`(账户→子) |
| `call.executionStatus` | `PENDING`、`PROCESSING`、`SUCCEEDED`、`FAILED`、`UNKNOWN` |
| `call.billingStatus` | `PROCESSING`、`SUCCESS`、`FAILED`、`SETTLE_PENDING`、`SETTLE_FAILED`、`UNKNOWN` |
| `call.dispatchPhase` | `REGISTERED`、`DISPATCHING`、`RESPONDED` |
| `call.retryHint` | `NONE`、`QUERY_ORIGINAL_REQUEST` |
| `consumption.settlementSource` | `GATEWAY_LOG`、`MANUAL`、`LEGACY` |
| `gift.status` | `PROCESSING`、`SUCCESS`、`FAILED`、`NEEDS_RECONCILIATION` |
| `gift.phase` | `REGISTERED`、`GATEWAY_WRITING`、`CONFIRMED`、`FAILED_CLEAR`、`NEEDS_RECONCILIATION` |
| gift reconcile `action` | `CONFIRM_APPLIED`、`CLOSE_NOT_APPLIED`、`RETRY_UNSENT` |
| call reconcile `action` | `BIND_GATEWAY_LOG`、`CLOSE_UNBILLED`、`SETTLE_MANUAL` |
| `operationStatus` | `PENDING`、`SUCCEEDED`、`FAILED`、`NEEDS_RECONCILIATION` |
| `businessCode` | `CHAT`、`MEIJI_SKIN_ANALYSIS` |
| SSE `finish_reason` | `stop`、`length`、`content_filter` |
## 附录 B:单位与常量
| 项 | 值 |
| --- | --- |
| `POINT_SCALE` | 10000(1 积分 = 10000 子单位) |
| `POINT_UNITS_PER_QUOTA` | 146(消耗子单位 = quota × 146) |
| `EXPECTED_QUOTA_PER_UNIT` | 500000(网关换算比例,健康检查校验) |
| 赠送换算 | `quota = (子单位 + 73) // 146`,到账 `quota × 146` |
| 验证向量 | 1000 积分 → 68493 quota → 999.9978;368 quota → 5.3728 |
| 正文保留 | 7 天 |
| 请求体上限 | 1 MiB(单条 message content 1 048 576 字符;messages ≤ 50 条) |
| 调用预算 | 150 秒 |
| 结算重试退避 | 1/5/15/60 分钟,最多 24 次(≈21.35 小时) |
| Key PENDING 有效期 | 24 小时 |
| 轮换宽限期 | 24 小时 |
| 月界 | Asia/Shanghai 自然月(UTC 前一日 16:00) |
## 附录 C:路由清单(35 个操作)
| # | 方法 | 路径 | 鉴权 | 幂等 |
| --- | --- | --- | --- | --- |
| 1 | GET | `/health/live` | 无 | — |
| 2 | GET | `/health/ready` | 无 | — |
| 3 | GET | `/api/v1/account` | CALL | — |
| 4 | POST | `/api/v1/account/availability` | CALL | — |
| 5 | POST | `/api/v1/chat/completions` | CALL | 调用域 |
| 6 | GET | `/api/v1/calls/{requestId}` | CALL | — |
| 7 | POST | `/api/v1/consumptions/page` | CALL | — |
| 8 | POST | `/v1/chat/completions` | CALL | 调用域(可省头) |
| 9 | GET | `/v1/models` | CALL | — |
| 10 | POST | `/internal/v1/accounts` | 管理 | 管理域 |
| 11 | POST | `/internal/v1/accounts/query` | 管理 | — |
| 12 | POST | `/internal/v1/accounts/{accountId}/status` | PLATFORM | 管理域 |
| 13 | POST | `/internal/v1/accounts/{accountId}/clients` | PLATFORM | 管理域 |
| 14 | GET | `/internal/v1/accounts/{accountId}/credentials` | 管理 | — |
| 15 | POST | `/internal/v1/accounts/{accountId}/credentials` | 管理 | 管理域 |
| 16 | POST | `/internal/v1/credentials/{credentialId}/activate` | 管理 | 管理域 |
| 17 | POST | `/internal/v1/credentials/{credentialId}/revoke` | 管理 | 管理域 |
| 18 | GET | `/internal/v1/accounts/{accountId}/sub-accounts` | 管理 | — |
| 19 | POST | `/internal/v1/accounts/{accountId}/sub-accounts` | 管理 | 管理域 |
| 20 | POST | `/internal/v1/accounts/{accountId}/sub-accounts/{subAccountId}/status` | 管理 | 管理域 |
| 21 | POST | `/internal/v1/accounts/{accountId}/sub-accounts/{subAccountId}/transfers` | 管理 | 管理域 |
| 22 | GET | `/internal/v1/accounts/{accountId}/scopes` | 管理 | — |
| 23 | POST | `/internal/v1/accounts/{accountId}/scopes` | 管理 | 管理域 |
| 24 | POST | `/internal/v1/accounts/{accountId}/scopes/{scopeId}/status` | 管理 | 管理域 |
| 25 | POST | `/internal/v1/accounts/{accountId}/scopes/{scopeId}/sub-account` | 管理 | 管理域 |
| 26 | POST | `/internal/v1/accounts/{accountId}/scopes/{scopeId}/assignment` | 管理 | 管理域 |
| 27 | POST | `/internal/v1/accounts/{accountId}/scopes/{scopeId}/quota` | 管理 | 管理域 |
| 28 | POST | `/internal/v1/gifts` | PLATFORM | 赠送域 |
| 29 | POST | `/internal/v1/gifts/page` | 管理 | — |
| 30 | GET | `/internal/v1/gifts/{giftId}` | 管理 | — |
| 31 | POST | `/internal/v1/gifts/{giftId}/reconcile` | PLATFORM | 管理域 |
| 32 | POST | `/internal/v1/consumptions/page` | 管理 | — |
| 33 | POST | `/internal/v1/consumptions/months` | 管理 | — |
| 34 | POST | `/internal/v1/calls/{callId}/reconcile` | PLATFORM | 管理域 |
| 35 | GET | `/internal/v1/requests/{operationType}/{requestId}` | 管理 | — |
> 路径中的 `{requestId}` 若含 `/` 等字符须 URL 编码(服务端按 path 型参数接收)。
## 附录 D:文档维护
- 本文档与 `docs/p0-contract-freeze.md`(契约)、`docs/account-hierarchy-and-scope-limits-design.md`(设计与取舍)配套;**接口实现变更时三者同批更新**。
- 生成依据(写文档时逐条核对代码):`app/account_main.py` 路由与 `read_posts` 只读白名单、`app/account_schemas.py` 入参约束、`app/account_service.py` / `app/account_ledger.py` / `app/account_gifts.py` / `app/account_calls.py` / `app/account_openai.py` 出参、`app/account_security.py` 鉴权与月键。
- 未做真实网关联调的部分(NewAPI 契约细节、真实 SSE、目标环境 TLS/网络)已在 §7.2/§7.5 标注,切勿据此文档假定线上已验证。
# 独立算力账户服务开发与迁移计划 # 独立算力账户服务开发与迁移计划
> SQL 整理更新(2026-09-30):系统尚未上线,当前初始化使用 `scripts/sql/schema.sql`,检查使用 `scripts/sql/check.sql`。SaaS 材料及历史脚本已分别移入 `scripts/sql/saas/`、`scripts/sql/archive/`,执行流程以 `scripts/sql/README.md` 为准。本文保留原阶段计划用于追溯。
- 日期:2026-09-23 - 日期:2026-09-23
- 状态:待实施的开发计划,不代表功能已完成或已授权上线 - 状态:待实施的开发计划,不代表功能已完成或已授权上线
- 范围:`mei1-computing-service`、`mei1-saas`、`business-saas/mwcloud`、`opt` - 范围:`mei1-computing-service`、`mei1-saas`、`business-saas/mwcloud`、`opt`
......
# P0 契约冻结:独立算力账户服务 # P0 契约冻结:独立算力账户服务
- 日期:2026-09-23 - 日期:2026-09-23
- 状态:契约冻结基线。本文字段、枚举、错误码与幂等规则一经确认,P1–P6 实现必须一致;变更需修订本文。 - 状态:契约冻结基线。本文字段、枚举、错误码与幂等规则一经确认,P1–P6 实现必须一致;变更需修订本文。**2026-09-29 修订**:并入账户层级与门店/员工限额(§2.10、§4 层级错误码、§5 层级指纹、§6 权限两行、§7.2 层级路由),上位设计为《账户层级与门店/员工限额设计》v0.2。**2026-09-30 修订**:层级写路由一律 POST + 动作路径(`SET_SCOPE_QUOTA` 由 `PUT .../quota` 改为 `POST .../quota`,语义不变)——`X-Request-Id` 仅对 POST 强制,动词统一后幂等强制在传输层成立,PUT 会留下无幂等键的写路径。**2026-09-30 修订二(业务口径,用户拍板)**:① §2.10 员工生效额度**取消回退链**——只认当前 (员工,所属门店) 配置行,该店无行即不限;② §4/§7.2 **账户级 CALL Key 仅 PLATFORM 可签发**(商户自签 403 `PERMISSION_DENIED`,reason=`ACCOUNT_KEY_PLATFORM_ONLY`),存量账户级 Key 不回收;③ 派发前(claim)**维持复查**月额度与 scope/分桶状态(上位设计 §7.1 ⑦ 后补注)。上位设计《账户层级与门店/员工限额设计》§3.3/§6/§7.1 与附录 A(S11/S13/S19/S33)同步修订。
- 上位文档:`docs/independent-account-development-plan.md`(边界、迁移与切流)。 - 上位文档:`docs/independent-account-development-plan.md`(边界、迁移与切流)。
- 旧文档适用性:`design.md` 中商户/门店/员工、共享 SaaS 库、公网 RSA 部分;`authentication.md` 全部公网签名协议——均被本契约为核心的独立账户路线取代,仅作历史参考,见文末标注。 - 旧文档适用性:`design.md` 中商户/门店/员工、共享 SaaS 库、公网 RSA 部分;`authentication.md` 全部公网签名协议——均被本契约为核心的独立账户路线取代,仅作历史参考,见文末标注。
...@@ -15,13 +15,17 @@ ...@@ -15,13 +15,17 @@
| `requestId` | 调用方业务幂等号 | ASCII 可打印字符 8–100,禁止空白;`accountId + clientId + requestId` 唯一定位一次调用;gift 与 call 命名空间独立 | | `requestId` | 调用方业务幂等号 | ASCII 可打印字符 8–100,禁止空白;`accountId + clientId + requestId` 唯一定位一次调用;gift 与 call 命名空间独立 |
| `callId` | 服务端调用记录 | 18 位数字字符串;兼任网关日志关联的内部 dispatchId 候选 | | `callId` | 服务端调用记录 | 18 位数字字符串;兼任网关日志关联的内部 dispatchId 候选 |
| `giftId` | 赠送记录 | 18 位数字字符串 | | `giftId` | 赠送记录 | 18 位数字字符串 |
| `subAccountId` | 子账号 | 18 位数字字符串;账户内余额分配主体(§2.10) |
| `scopeId` | 门店/员工主体 | 18 位数字字符串;由 CALL Key 绑定,不由请求参数指定(§2.10) |
| `scopeKey` | 来源系统的 opaque 标识 | ASCII `^[a-z0-9-]{1,30}:[A-Za-z0-9._:-]{1,69}$`(总长 ≤100,来源前缀 + 实体标识),本服务不解析;同一实体必须固定由同一来源系统上报 |
| `quotaMonth` | 员工月度用量状态键 | `'YYYY-MM'`,由调用登记时间按 Asia/Shanghai 派生(月界 = 北京 0 点 = UTC 前一日 16:00);时间戳列一律 UTC,此为唯一时区派生业务键 |
| `businessRef` | 业务引用 | ASCII 1–100,仅备注追踪,不参与鉴权与归属 | | `businessRef` | 业务引用 | ASCII 1–100,仅备注追踪,不参与鉴权与归属 |
写操作必须带 `X-Request-Id` 头。`X-App-Id` 不再作为身份输入;如出现仅记录不采信。 写操作必须带 `X-Request-Id` 头。`X-App-Id` 不再作为身份输入;如出现仅记录不采信。
## 2. 数据字典 ## 2. 数据字典
类型按 MySQL >= 8.0.16。所有表含 `create_time`、`last_update_time`(UTC DATETIME(3));账户/来源/凭证停用用状态表达,不物理删除账务和幂等记录。标识列为 ASCII/ascii_bin,展示文本为 utf8mb4/utf8mb4_bin;HTTP 边界须额外拒绝空白及非法字符,不能单靠排序规则做格式校验。完整列定义与索引见 P1 生成的 `scripts/20260923_independent_account_ddl.sql`。 类型按 MySQL >= 8.0.16。所有表含 `create_time`、`last_update_time`(UTC DATETIME(3));账户/来源/凭证停用用状态表达,不物理删除账务和幂等记录。标识列为 ASCII/ascii_bin,展示文本为 utf8mb4/utf8mb4_bin;HTTP 边界须额外拒绝空白及非法字符,不能单靠排序规则做格式校验。完整列定义与索引见由 ORM 生成的当前全量基线 `scripts/sql/schema.sql`;SQL 使用流程见 `scripts/sql/README.md`。开发期间不再叠加日期增量。
### 2.1 account ### 2.1 account
...@@ -64,6 +68,8 @@ ...@@ -64,6 +68,8 @@
| issued_by | BIGINT UNSIGNED NULL | 签发凭证 id;仅受控 bootstrap 管理 Key 可空,CALL Key 必填 | | issued_by | BIGINT UNSIGNED NULL | 签发凭证 id;仅受控 bootstrap 管理 Key 可空,CALL Key 必填 |
| revoke_reason | VARCHAR(200) NULL | | | revoke_reason | VARCHAR(200) NULL | |
层级扩展(§2.10):`sub_account_id BIGINT UNSIGNED NULL`、`scope_id BIGINT UNSIGNED NULL`,**仅 CALL 类可携带**且二者互斥(CHECK 不得同时非空);两者必须与 `account_id` 同账户(应用层校验)。四类 CALL Key:账户级(均 NULL)、子账号级(sub_account_id)、门店级(scope_id=STORE)、员工级(scope_id=EMPLOYEE)。调用主体由 Key 决定,不接受请求参数指定主体。
校验顺序:凭证存在 → 摘要恒定时间比较 → 状态/有效期 → client/account 授权。授权被撤销优先于幂等重放:撤销后的 Key 连重放也不可用。 校验顺序:凭证存在 → 摘要恒定时间比较 → 状态/有效期 → client/account 授权。授权被撤销优先于幂等重放:撤销后的 Key 连重放也不可用。
### 2.5 management_request ### 2.5 management_request
...@@ -72,7 +78,7 @@ ...@@ -72,7 +78,7 @@
| --- | --- | | --- | --- |
| id BIGINT UNSIGNED PK | | | id BIGINT UNSIGNED PK | |
| client_id VARCHAR(50) NOT NULL | 管理主体来源 | | client_id VARCHAR(50) NOT NULL | 管理主体来源 |
| operation_type ENUM('PROVISION_ACCOUNT','ISSUE_CALL_KEY','ACTIVATE_CREDENTIAL','REVOKE_CREDENTIAL','GRANT_CLIENT','REVOKE_CLIENT','SET_ACCOUNT_STATUS','RECONCILE') | | | operation_type ENUM('PROVISION_ACCOUNT','ISSUE_CALL_KEY','ACTIVATE_CREDENTIAL','REVOKE_CREDENTIAL','GRANT_CLIENT','REVOKE_CLIENT','SET_ACCOUNT_STATUS','RECONCILE','CREATE_SUB_ACCOUNT','SET_SUB_ACCOUNT_STATUS','TRANSFER','BIND_SCOPE','SET_SCOPE_STATUS','MOVE_STORE','MOVE_EMPLOYEE','SET_SCOPE_QUOTA') | |
| request_id VARCHAR(100) NOT NULL | 调用方业务幂等号 | | request_id VARCHAR(100) NOT NULL | 调用方业务幂等号 |
| fingerprint CHAR(64) NOT NULL | 规范化参数摘要,同号异参 409 | | fingerprint CHAR(64) NOT NULL | 规范化参数摘要,同号异参 409 |
| target_id BIGINT UNSIGNED NULL | 目标 account/credential/gift | | target_id BIGINT UNSIGNED NULL | 目标 account/credential/gift |
...@@ -80,7 +86,7 @@ ...@@ -80,7 +86,7 @@
| result_ref JSON NULL | 结果引用(account_id、credential_id 等),不存 secret | | result_ref JSON NULL | 结果引用(account_id、credential_id 等),不存 secret |
| 唯一 | `(client_id, operation_type, request_id)` | | 唯一 | `(client_id, operation_type, request_id)` |
开户、签发、激活、撤销、来源授权、账户状态与核清走本表登记;轮换使用激活操作携带 replaced credential。赠送仅使用 gift 表独立三元幂等域,避免同一管理来源在不同账户同号时被误拦。已有 accountId 的操作另带目标校验。 开户、签发、激活、撤销、来源授权、账户状态与核清走本表登记;层级管理(子账号/转账/门店/员工/额度,§2.10 与 §7.2)同样走本表,**建档与停启用拆分为不同 operation_type**(同号先建后停不得共用指纹)。轮换使用激活操作携带 replaced credential。赠送仅使用 gift 表独立三元幂等域,避免同一管理来源在不同账户同号时被误拦。已有 accountId 的操作另带目标校验。
### 2.6 gift ### 2.6 gift
...@@ -118,17 +124,30 @@ id PK嫣account_id`縲〜client_id`縲〜request_id`帛髪荳 `(account_id, client_ ...@@ -118,17 +124,30 @@ id PK嫣account_id`縲〜client_id`縲〜request_id`帛髪荳 `(account_id, client_
| lease_owner VARCHAR(64) NULL, lease_until DATETIME(3) NULL, version INT NOT NULL DEFAULT 0 | 补偿租约 | | lease_owner VARCHAR(64) NULL, lease_until DATETIME(3) NULL, version INT NOT NULL DEFAULT 0 | 补偿租约 |
| reconciled_time DATETIME(3) NULL, reconciliation_note VARCHAR(500) NULL | 人工核清 | | reconciled_time DATETIME(3) NULL, reconciliation_note VARCHAR(500) NULL | 人工核清 |
层级快照扩展(§2.10):`sub_account_id`、`scope_id`、`store_scope_id`(均 BIGINT UNSIGNED NULL)、`quota_month CHAR(7) NULL`。**登记时一次性快照,结算只认快照**:员工换店、门店改派、直挂切换、跨月重试均不改变在途调用的归属与计费月。快照取值按凭证类型固定(门店 Key:scope_id=store_scope_id=门店;员工 Key:scope_id=员工、store_scope_id=当前门店;账户/子账号 Key:均 NULL)。`quota_month` = 登记时间按 Asia/Shanghai 的自然月(员工 Key 必填,其余 NULL)。
执行成功与结算成功分别表达。SUCCESS/settled_flag=1 表示已入账;FAILED/settled_flag=0 表示已证实未计费关闭,两者均不自动重发。其余状态未终结,不能仅设置 reconciled_time 放行。`resultAvailable = result_json IS NOT NULL`,正文过期不改变账务终态。 执行成功与结算成功分别表达。SUCCESS/settled_flag=1 表示已入账;FAILED/settled_flag=0 表示已证实未计费关闭,两者均不自动重发。其余状态未终结,不能仅设置 reconciled_time 放行。`resultAvailable = result_json IS NOT NULL`,正文过期不改变账务终态。
### 2.8 consumption、point_record、id_segment、operation_audit ### 2.8 consumption、point_record、id_segment、operation_audit
- consumption:独立表 `t_computing_consumption`,不保留 source_app 或商户字段;唯一 `(request_id, account_id, client_id)`,call_id 非空唯一。使用复合外键 `(call_id, account_id, client_id)` 防止跨账户/来源关联。保存 gateway_request_id、gateway_token_id、business_code/ref、model、四类 usage、consumed_quota、consumed_point_units、point_units_per_quota、settlement_status/source、settled_time。`legacy_record=true` 才允许缺 call_id;历史 source_app 在迁移中映射成 client_id,不在新表冗余保存。 - consumption:独立表 `t_computing_consumption`,不保留 source_app 或商户字段;唯一 `(request_id, account_id, client_id)`,call_id 非空唯一。使用复合外键 `(call_id, account_id, client_id)` 防止跨账户/来源关联。保存 gateway_request_id、gateway_token_id、business_code/ref、model、四类 usage、consumed_quota、consumed_point_units、point_units_per_quota、settlement_status/source、settled_time。`legacy_record=true` 才允许缺 call_id;历史 source_app 在迁移中映射成 client_id,不在新表冗余保存。层级扩列(§2.10):`sub_account_id`、`scope_id`、`store_scope_id`(均 NULL,取 call 登记快照),是子账号/门店/员工分账的唯一数据源;多上游扩列(usage_raw/pricing_version_id 等)见《多上游与定价设计》§3.3/§3.4,修订时在此一并列全。
- 消费 SUCCESS 必须同时有非负 quota、精确金额、正换算快照、结算时间及 GATEWAY_LOG/MANUAL/LEGACY 来源;未结算金额保持 NULL。call 与消费的双向对应由同一结算事务和对账保证,不能仅靠各自 UNIQUE。 - 消费 SUCCESS 必须同时有非负 quota、精确金额、正换算快照、结算时间及 GATEWAY_LOG/MANUAL/LEGACY 来源;未结算金额保持 NULL。call 与消费的双向对应由同一结算事务和对账保证,不能仅靠各自 UNIQUE。
- point_record:独立表 `t_computing_point_record`,type 仅 GIFT/CONSUME,不增加任意余额 ADJUST 能力;人工核清仍走对应赠送/消费事务。call_id、gift_id、consumption_record_id 分别可空唯一并以复合外键约束同账户/来源;request_id 只用于追踪,不单独唯一。GIFT 金额为正,CONSUME 为负,`after = before + point_units`。legacy_record 仅为已核对历史记录允许缺新调用/赠送关联,不跳过消费记录关联。 - point_record:独立表 `t_computing_point_record`,type 仅 GIFT/CONSUME/**TRANSFER_OUT/TRANSFER_IN**,不增加任意余额 ADJUST 能力;人工核清仍走对应赠送/消费事务。**账本分桶**:新增 `sub_account_id BIGINT UNSIGNED NULL`,NULL = 账户桶、非空 = 子账号桶;GIFT 记账户桶;CONSUME 按扣费桶(账户级 Key 与直挂门店/员工记账户桶,其余记对应子账号桶);TRANSFER_OUT/IN 与同 transferId 成对、同事务、金额相等方向相反(守恒)。call_id、gift_id、consumption_record_id 分别可空唯一并以复合外键约束同账户/来源;request_id 只用于追踪,不单独唯一。GIFT 金额为正,CONSUME 为负,`after = before + point_units`。legacy_record 仅为已核对历史记录允许缺新调用/赠送关联,不跳过消费记录关联。
- id_segment:独立表 `t_computing_id_segment`,单行 computing-global;高水位范围 `[100000000000000000, 1000000000000000000]`,上界是耗尽哨兵不发号。迁移取旧已提交高水位(含已领未用号段),禁止回拨与 MAX(id)+1。 - id_segment:独立表 `t_computing_id_segment`,单行 computing-global;高水位范围 `[100000000000000000, 1000000000000000000]`,上界是耗尽哨兵不发号。迁移取旧已提交高水位(含已领未用号段),禁止回拨与 MAX(id)+1。
- operation_audit:id、actor_credential_id、client_id、account_id、action、target_type/target_id、request_id、from_status/to_status、reason VARCHAR(500)、evidence_ref JSON、create_time。只追加;受控初始化允许操作者凭证为空,不伪造不存在的签发者。 - operation_audit:id、actor_credential_id、client_id、account_id、action、target_type/target_id、request_id、from_status/to_status、reason VARCHAR(500)、evidence_ref JSON、create_time。只追加;受控初始化允许操作者凭证为空,不伪造不存在的签发者。层级扩展:`target_type` 增 SUB_ACCOUNT/SCOPE/SCOPE_QUOTA;evidence_ref 记录额度/归属**前后值**,SET_SCOPE_QUOTA 须记录该次写入后的行 last_update_time(与业务行同事务取同一时间值;无回退链后不参与解析排序,仅作排障依据)。
### 2.10 账户层级:子账号、门店/员工 scope(2026-09-29 并入,设计明细见《账户层级与门店/员工限额设计》v0.2)
新增 4 张表(总 15 张 `t_computing_*`):
P1 物理模型固定为 11 张 `t_computing_*` 表;金额/usage 是有符号 BIGINT,正文使用 MEDIUMTEXT 以容纳协议的 1 MiB 上限,UTC 时间保存毫秒。DDL 从 `app/account_models.py` 生成,不创建或修改任何 SaaS 表。独立库连接要求 READ COMMITTED、UTC、严格 SQL 模式,MySQL 最低 8.0.16 才能强制执行 CHECK。 - **sub_account**:id、account_id、name、remark、status(ACTIVE/DISABLED)、balance_point_units、version;`UNIQUE(account_id, name)`。不绑网关令牌。**分配即冻结**:账户余额条件扣减转入、子账号余额条件扣减回流;余额不足整单拒绝、无部分转账;转账为纯本地单事务,**无门闩**,结果不明按原 requestId 回查 management_request。子账号互转不做(回流+再分配)。
- **scope**:id、account_id、`sub_account_id NULL`(仅门店行可非空;NULL = 直挂账户,共用账户未分配余额)、scope_type(STORE/EMPLOYEE)、scope_key(§1 格式)、parent_scope_id NULL(员工→门店;门店必 NULL)、status、version。`UNIQUE(scope_type, scope_key)` 全局唯一(一店一账号跨账户硬保证)。`scope_type` 不可变;跨行约束(员工 parent 必为同账户 STORE、门店 sub_account_id 同账户)走应用层同事务校验 + 对账兜底;跨商户迁移不支持(换 key 重建)。
- **scope_quota**(员工×门店月度额度):id、scope_id(EMPLOYEE)、store_scope_id(STORE)、`monthly_quota_point_units BIGINT NULL`(CHECK ≥0;行存在即已配置:NULL=显式不限、0=零额度、>0=月上限)、version;`UNIQUE(scope_id, store_scope_id)`。**生效额度解析**(2026-09-30 决策,**无回退链**):当前 (员工,所属门店) 的配置行 → 该门店无行 = **不限**(不取该员工其他门店的配置值,不让停用门店行参与解析);行存在而值为 NULL = 显式不限。换店保留的是**已用量**(跨店累计、不清零),配置取值只认当前门店。
- **scope_month_usage**(员工月用量):scope_id、`month CHAR(7)`(Asia/Shanghai 自然月 = 登记时间所在月,快照进 call)、used_point_units;`UNIQUE(scope_id, month)`。月份是状态键:新月首条从 0 开始,无定时重置任务。累加用**原子 UPSERT**(MySQL `INSERT … AS new ON DUPLICATE KEY UPDATE`,SQLite 走 ON CONFLICT),月份取登记快照不取结算时刻。
员工月度限额语义(冻结):仅员工 Key 受限;按自然月重置、每月额度 = 重置时刻 §2.10 解析值;月中调整立即生效;换店用量跨店累计(不清零);额度调低于已用允许(拒新调用至上调或次月);**软限额**(准入只读比对,不预留不预扣,超额窗口 ≈ 并途 150s + 补偿退避 ≈21 小时,靠告警兜底)。门店无限额(门店消耗上限 = 所属子账号余额或直挂时账户未分配余额)。
P1 物理模型固定为 11 张 `t_computing_*` 表;**层级扩展新增 4 张(sub_account/scope/scope_quota/scope_month_usage,共 15 张,§2.10)**;金额/usage 是有符号 BIGINT,正文使用 MEDIUMTEXT 以容纳协议的 1 MiB 上限,UTC 时间保存毫秒。DDL 从 `app/account_models.py` 生成,不创建或修改任何 SaaS 表。独立库连接要求 READ COMMITTED、UTC、严格 SQL 模式,MySQL 最低 8.0.16 才能强制执行 CHECK。
### 2.9 P1 补齐字段与实现边界(2026-09-23) ### 2.9 P1 补齐字段与实现边界(2026-09-23)
...@@ -160,6 +179,8 @@ P1 迚ゥ逅ィ。蝙句崋螳壻クコ 11 蠑 `t_computing_*` 陦ィ幃鬚/usage 譏ッ譛臥ャヲ蜿キ ...@@ -160,6 +179,8 @@ P1 迚ゥ逅ィ。蝙句崋螳壻クコ 11 蠑 `t_computing_*` 陦ィ幃鬚/usage 譏ッ譛臥ャヲ蜿キ
非法转换一律拒绝并告警:不允许 SUCCESS→FAILED、迟到 worker 不得覆盖终态(version 条件写)。FAILED 仅用于有证据未受理且未计费;有 usage/结果但缺网关 ID 不是 FAILED。UNKNOWN 终局只有两条路:找到证据结算成功,或人工确认未计费后关闭;永不自动重发模型。 非法转换一律拒绝并告警:不允许 SUCCESS→FAILED、迟到 worker 不得覆盖终态(version 条件写)。FAILED 仅用于有证据未受理且未计费;有 usage/结果但缺网关 ID 不是 FAILED。UNKNOWN 终局只有两条路:找到证据结算成功,或人工确认未计费后关闭;永不自动重发模型。
层级结算扩展(§2.10):结算事务在同一提交内追加——扣费桶余额条件扣减(账户桶或子账号桶)、`scope_month_usage` 原子 UPSERT(月份取登记快照)、consumption 落 scope 快照、point_record 落对应桶。锁序固定:`account → sub_account → scope(按 id 升序)→ scope_month_usage`,准入与结算两端一致。重复结算由 call_id 唯一 + version 围栏拒绝。员工月度限额为软限额:准入只读比对,不因在途超额回滚已受理调用。
### 3.2 gift ### 3.2 gift
REGISTERED → GATEWAY_WRITING → CONFIRMED(记账 SUCCESS); REGISTERED → GATEWAY_WRITING → CONFIRMED(记账 SUCCESS);
...@@ -186,26 +207,36 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧 ...@@ -186,26 +207,36 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
| CREDENTIAL_REVOKED | 401 | 已撤销 | | CREDENTIAL_REVOKED | 401 | 已撤销 |
| ACCOUNT_ACCESS_REVOKED | 403 | client 授权被撤 | | ACCOUNT_ACCESS_REVOKED | 403 | client 授权被撤 |
| ACCOUNT_DISABLED | 403 | 账户停用 | | ACCOUNT_DISABLED | 403 | 账户停用 |
| PERMISSION_DENIED | 403 | 角色越权 | | SUB_ACCOUNT_DISABLED | 403 | 子账号停用:其下全部新调用拒绝,在途照常结算 |
| SCOPE_DISABLED | 403 | 门店/员工 scope 停用(含员工 parent 门店停用):新调用拒绝,在途照常结算 |
| PERMISSION_DENIED | 403 | 角色越权(含账户级 Key 仅 PLATFORM 可签发,reason=`ACCOUNT_KEY_PLATFORM_ONLY`) |
| ACCOUNT_NOT_FOUND | 404 | | | ACCOUNT_NOT_FOUND | 404 | |
| REQUEST_NOT_FOUND | 404 | 查询的原请求不存在 | | REQUEST_NOT_FOUND | 404 | 查询的原请求不存在 |
| FINGERPRINT_MISMATCH | 409 | 同号异参 | | FINGERPRINT_MISMATCH | 409 | 同号异参 |
| ACCOUNT_BLOCKED | 409 | UNKNOWN/未核清 SETTLE_FAILED/赠送占用等准入阻断;data.reason 给出原因 | | ACCOUNT_BLOCKED | 409 | UNKNOWN/未核清 SETTLE_FAILED/赠送占用等准入阻断;data.reason 给出原因(INSUFFICIENT_BALANCE = 余额不足,data 可含 subAccountId) |
| SCOPE_CONFLICT | 409 | scope_key 已存在(一店一账号),须显式改派 |
| GIFT_GATE_BUSY | 409 | 门闩被其他赠送占用 | | GIFT_GATE_BUSY | 409 | 门闩被其他赠送占用 |
| RATE_LIMITED | 429 | 并发/容量上限 | | RATE_LIMITED | 429 | 并发/容量上限 |
| EMPLOYEE_QUOTA_EXCEEDED | 429 | 员工月度额度不足;data 带 scopeId/quotaMonth/limitPointUnits/usedPointUnits(子单位十进制字符串) |
| DEPENDENCY_UNAVAILABLE | 503 | 配置/DB/网关未就绪,未登记新请求 | | DEPENDENCY_UNAVAILABLE | 503 | 配置/DB/网关未就绪,未登记新请求 |
| SERVICE_UNAVAILABLE | 503 | 兜底,已登记请求凭 requestId 查询 | | SERVICE_UNAVAILABLE | 503 | 兜底,已登记请求凭 requestId 查询 |
401/403 优先于重放;可重试的是 429/503 与 202 后查询,5xx 不得一律当"可重发新请求"。 401/403 优先于重放;可重试的是 429/503 与 202 后查询,5xx 不得一律当"可重发新请求"。余额不足沿用 ACCOUNT_BLOCKED(409)+reason=INSUFFICIENT_BALANCE 不另立新码(data 可含 subAccountId;OpenAI 兼容层既有映射 429 insufficient_quota 不变;EMPLOYEE_QUOTA_EXCEEDED 新增同映射分支)。
## 5. 幂等与指纹 ## 5. 幂等与指纹
- 调用幂等域:`(account_id, client_id, request_id)`;gift 独立同构;管理操作 `(client_id, operation_type, request_id)`。 - 调用幂等域:`(account_id, client_id, request_id)`;gift 独立同构;管理操作 `(client_id, operation_type, request_id)`。
- 重放优先级:有效凭证校验通过后,先查原记录并比对指纹;命中即返回持久化状态(202/200),不做新准入检查(不因余额变 0 拒绝重放)。仅凭证/授权失效会让重放本身失败。 - 重放优先级:有效凭证校验通过后,先查原记录并比对指纹;命中即返回持久化状态(202/200),不做新准入检查(不因余额变 0 拒绝重放)。仅凭证/授权失效会让重放本身失败。
- 指纹输入(规范化 JSON,sort_keys、紧凑分隔符、SHA-256):`{modelSelection: requested_model|null, businessCode, businessRef, messages:[{role,content}]}`。不含 Key 版本、员工/门店等可变主数据;备注变更不改指纹。 - 指纹输入(规范化 JSON,sort_keys、紧凑分隔符、SHA-256):`{modelSelection: requested_model|null, businessCode, businessRef, messages:[{role,content}]}`。不含 Key 版本、员工/门店等可变主数据;备注变更不改指纹。层级扩展:**仅追加凭证静态主体**——`subject: {subAccountId, scopeId}`(credential 绑定值,Key 生命周期内不变);同号换不同主体的 Key = 异参(FINGERPRINT_MISMATCH),同主体轮换 Key 不影响重放。解析出的动态快照(当前门店/解析子账号/月份)**不进指纹**,只落 call 行。
- gift 指纹输入:`{account_id, points, operator_note}`(账户由凭证域固定时仍显式参与)。 - gift 指纹输入:`{account_id, points, operator_note}`(账户由凭证域固定时仍显式参与)。
- 层级管理操作指纹输入(扩展现有操作时字段名逐字沿用既有命名——ISSUE_CALL_KEY 为 camelCase,gift 为 snake_case;**签发指纹必须扩为 `{accountId, clientId, subAccountId?, scopeId?}`**,否则同号给不同员工签发会被误判重放):
- CREATE_SUB_ACCOUNT `{accountId, name}`;SET_SUB_ACCOUNT_STATUS `{subAccountId, status}`;
- TRANSFER `{accountId, subAccountId, points, direction}`;
- BIND_SCOPE `{accountId, scopeType, scopeKey, subAccountId?, parentScopeKey?}`(subAccountId 省略=直挂账户,与显式 null 等价);
- SET_SCOPE_STATUS `{scopeId, status}`;MOVE_STORE `{scopeId, subAccountId}`(目标可 null=直挂);MOVE_EMPLOYEE `{scopeId, parentScopeId}`;
- SET_SCOPE_QUOTA `{scopeId, storeScopeId, monthlyQuotaPoints}`。
- 唯一冲突后必须回查原记录核对指纹,不返回裸 IntegrityError。 - 唯一冲突后必须回查原记录核对指纹,不返回裸 IntegrityError。
- 精度:所有 id、point_units、quota、token 计数为 JSON 十进制字符串;`page/size/total` 为 number;积分展示为固定 4 位十进制字符串。时间一律 ISO 8601 带时区(UTC `Z`)。 - 精度:所有 id、point_units、quota、token 计数为 JSON 十进制字符串;`page/size/total` 为 number;积分展示为固定 4 位十进制字符串。时间一律 ISO 8601 带时区(UTC `Z`)。层级接口金额入参为 points(积分,严格整数);存储与出参的子单位 = points × 10000(结算按 quota × 146 入账)。
## 6. 固定权限矩阵 ## 6. 固定权限矩阵
...@@ -216,6 +247,8 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧 ...@@ -216,6 +247,8 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
| 跨来源账户账务摘要 | 禁止 | 允许(限自有账户) | 允许 | | 跨来源账户账务摘要 | 禁止 | 允许(限自有账户) | 允许 |
| 幂等开户 | 禁止 | 允许(owner=本 client) | 允许 | | 幂等开户 | 禁止 | 允许(owner=本 client) | 允许 |
| 签发/激活/撤销本来源调用 Key | 禁止 | 允许(限自有账户) | 允许 | | 签发/激活/撤销本来源调用 Key | 禁止 | 允许(限自有账户) | 允许 |
| 创建子账号、建/停门店与员工、员工换店、门店改派、设员工月额度 | 禁止 | 允许(限自有账户;subAccountId/scopeId 必属本账户) | 允许(显式目标账户) |
| 余额划拨(总↔子转账) | 禁止 | 允许(限自有账户,不动总量) | 允许 |
| 授权/撤销 client 接入、账户停启用 | 禁止 | 禁止 | 允许 | | 授权/撤销 client 接入、账户停启用 | 禁止 | 禁止 | 允许 |
| 赠送、人工核清 | 禁止 | 禁止 | 允许(不得任意修改余额) | | 赠送、人工核清 | 禁止 | 禁止 | 允许(不得任意修改余额) |
| 读取其他 client 模型正文 | 禁止 | 禁止 | 禁止(仅运维只读归档) | | 读取其他 client 模型正文 | 禁止 | 禁止 | 禁止(仅运维只读归档) |
...@@ -272,8 +305,18 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧 ...@@ -272,8 +305,18 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
- `GET /api/v1/account`:`balancePointUnits`(字符串,可负)、`available`(bool,>0 且 ACTIVE 且无阻断)。 - `GET /api/v1/account`:`balancePointUnits`(字符串,可负)、`available`(bool,>0 且 ACTIVE 且无阻断)。
- `POST /internal/v1/accounts` 请求:`{requestId, name, remark}`;响应含 `accountId`、`provisionStatus`。重复 request 返回首次结果。 - `POST /internal/v1/accounts` 请求:`{requestId, name, remark}`;响应含 `accountId`、`provisionStatus`。重复 request 返回首次结果。
- 签发响应:`{credentialId, secret}`,`secret` 仅此一次;重复 request 返回 `{credentialId, status, secretAvailable:false}`。 - 签发响应:`{credentialId, secret}`,`secret` 仅此一次;重复 request 返回 `{credentialId, status, secretAvailable:false}`。签发请求扩展 `{subAccountId?, scopeId?}`(二选一,必须属目标账户;指纹见 §5)。**账户级(两者均空)仅 PLATFORM 可签发**:商户自签返回 403 `PERMISSION_DENIED`(reason=`ACCOUNT_KEY_PLATFORM_ONLY`);商户路径为门店/员工 Key,无子账号时门店直挂账户(D10);存量账户级 Key 不回收,认证与扣费不变。
- 赠送请求:`{requestId, accountId, points(数字1..10^9), operatorNote}`;响应含 `giftId, status, creditedPoints(字符串|null)`。 - 赠送请求:`{requestId, accountId, points(数字1..10^9), operatorNote}`;响应含 `giftId, status, creditedPoints(字符串|null)`。
- 层级管理路由(§2.10 语义的冻结形态;全部 management_request 幂等域 + X-Request-Id;金额入参 points 严格整数,出参子单位十进制字符串;明细见《账户层级与门店/员工限额设计》v0.2 §6):
- `POST /internal/v1/accounts/{accountId}/sub-accounts` `{requestId, name, remark}` → `{subAccountId, status, balancePointUnits}`;重放返回同一 subAccountId。
- `POST /internal/v1/accounts/{accountId}/sub-accounts/{subAccountId}/status` `{requestId, status}` → 停用拒新调用(403 SUB_ACCOUNT_DISABLED),在途照常结算,余额不自动回流。
- `POST /internal/v1/accounts/{accountId}/sub-accounts/{subAccountId}/transfers` `{requestId, points(1..10^9), direction: IN|OUT, operatorNote?}` → `{subAccountId, direction, points, balancePointUnits}`(OUT=总→子);余额不足整单 409 拒绝,无部分转账。
- `POST /internal/v1/accounts/{accountId}/scopes` `{requestId, scopeType: STORE|EMPLOYEE, scopeKey, subAccountId?, parentScopeKey?}` → `{scopeId, status}`;scope_key 全局撞号 409 SCOPE_CONFLICT;员工必须带 parentScopeKey。
- `POST /internal/v1/accounts/{accountId}/scopes/{scopeId}/status` `{requestId, status}` → 拒新调用(403 SCOPE_DISABLED),在途照常结算,配置行保留。
- `POST /internal/v1/accounts/{accountId}/scopes/{scopeId}/sub-account` `{requestId, subAccountId|null}` → 门店改派/直挂双向;不移动任何余额。
- `POST /internal/v1/accounts/{accountId}/scopes/{scopeId}/assignment` `{requestId, parentScopeId}` → 员工换店;当月已用跨店累计,生效额度按 §2.10 解析。
- `POST /internal/v1/accounts/{accountId}/scopes/{scopeId}/quota` `{requestId, storeScopeId, monthlyQuotaPoints|null}` → 立即生效(null=显式不限);低于已用允许,拒新调用至上调或次月;审计前后值。
- 查询扩展:`GET /internal/v1/accounts/{accountId}/sub-accounts`(分页,含各桶余额);`GET /internal/v1/accounts/{accountId}/scopes`(分页,类型/门店过滤);`GET /api/v1/account` 增加 `subAccounts[].balancePointUnits` 与 `unallocatedPointUnits`;账本分页(gift/consumption)增加 `subAccountId/scopeId/storeScopeId` 过滤与投影列。
## 8. 旧调用方清单与迁移映射 ## 8. 旧调用方清单与迁移映射
...@@ -325,7 +368,9 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧 ...@@ -325,7 +368,9 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
- 已派发的核清必须超过持久化派发窗口,并附带能定位原操作终局的外部证据,确认原服务 worker 与网关请求均已排空/停止、旧写绝无迟到可能;暂停新流量或超时本身不等于排空。实时只读还须匹配原 Token/usedQuota/配置,以及确认到账时的目标额度或确认未到账时的原额度。任一不符则拒绝。服务不能校验证据编号背后的事实,操作责任人必须先审核;不能把一次旧值/目标值读数当终局证据。 - 已派发的核清必须超过持久化派发窗口,并附带能定位原操作终局的外部证据,确认原服务 worker 与网关请求均已排空/停止、旧写绝无迟到可能;暂停新流量或超时本身不等于排空。实时只读还须匹配原 Token/usedQuota/配置,以及确认到账时的目标额度或确认未到账时的原额度。任一不符则拒绝。服务不能校验证据编号背后的事实,操作责任人必须先审核;不能把一次旧值/目标值读数当终局证据。
- 核清重新锁 account→gift→management_request,复核版本/绑定/未清调用;迟到的自动写回不能覆盖核清结果。仅填 reconciledTime 不释放门闩,不提供任意余额 ADJUST。失败的只读预检可明确 FAILED_CLEAR;普通重放仍不重试,需上述受控动作。新意图不是未知赠送的恢复手段。 - 核清重新锁 account→gift→management_request,复核版本/绑定/未清调用;迟到的自动写回不能覆盖核清结果。仅填 reconciledTime 不释放门闩,不提供任意余额 ADJUST。失败的只读预检可明确 FAILED_CLEAR;普通重放仍不重试,需上述受控动作。新意图不是未知赠送的恢复手段。
- `POST /internal/v1/gifts/page` 与 `/internal/v1/consumptions/page` 为管理账务查询:INTEGRATION 只看自有且自身接入未撤销账户的跨来源财务信息;PLATFORM 必须提供 ownerClientId 或 accountIds。可选 clientId/requestId/start/end;accountIds 上限 1000,page/size 默认 1/20、size 上限 200,不截断账户集合。`POST /api/v1/consumptions/page` 仅 CALL,固定凭证的 account/client,拒绝传入身份覆盖筛选。 - `POST /internal/v1/gifts/page` 与 `/internal/v1/consumptions/page` 为管理账务查询:INTEGRATION 只看自有且自身接入未撤销账户的跨来源财务信息;PLATFORM 必须提供 ownerClientId 或 accountIds。可选 clientId/requestId/start/end;accountIds 上限 1000,page/size 默认 1/20、size 上限 200,不截断账户集合。`POST /api/v1/consumptions/page` 仅 CALL,固定凭证的 account/client,拒绝传入身份覆盖筛选。
- **分账维度(2026-09-30,段 3 落地)**:`/internal/v1/consumptions/page` 增可选 `subAccountIds` / `scopeIds` / `storeScopeIds`(各 1–1000,与 accountIds 同口径):门店账 = `storeScopeIds`(门店 Key 与员工 Key 统一落归属门店)、员工账 = `scopeIds`、子账号账 = `subAccountIds`(NULL = 账户桶:直挂门店/员工的消费与账户级消费同桶,scope 快照保留)。三者是**账户范围内的过滤器**,不能借此跨账户(他人 scope/子账号 ID 只得到空结果);CALL 主体传入任一维度照旧 `403 PERMISSION_DENIED`;gifts 查询无分账维度,传入返回 `400 INVALID_ARGUMENT`。响应行增 `subAccountId` / `scopeId` / `storeScopeId`(缺失为 null);call 与 legacy consumption 两条投影同口径。
- 时间输入必须有时区,统一 UTC,start 含、end 不含;按 createTime/id 倒序。消费查询合并全部 call 与没有 call 的 legacy 消费,已结算 call 不重复显示 consumption;未结算金额保持 NULL,不读取模型正文。平台分页/详情查询审计。只读分页无需 X-Request-Id,body.requestId 在此为筛选字段而非操作号。 - 时间输入必须有时区,统一 UTC,start 含、end 不含;按 createTime/id 倒序。消费查询合并全部 call 与没有 call 的 legacy 消费,已结算 call 不重复显示 consumption;未结算金额保持 NULL,不读取模型正文。平台分页/详情查询审计。只读分页无需 X-Request-Id,body.requestId 在此为筛选字段而非操作号。
- **月账单直读(2026-09-30,段 3 落地)**:`POST /internal/v1/consumptions/months` — 请求 `{accountIds?, ownerClientId?, month?, subAccountIds?, scopeIds?, storeScopeIds?, page, size}`:`month` 缺省当月(Asia/Shanghai),须 `YYYY-MM` 否则 `400 INVALID_ARGUMENT`;三个维度同分账维度的口径与上限。管理查询(PLATFORM 需 ownerClientId 或 accountIds;INTEGRATION 限自有账户;**CALL 主体 `403 PERMISSION_DENIED`**),只读分页无需 `X-Request-Id`。响应 `{total, page, size, month, list}`,行 = `{scopeId, scopeType, scopeKey, storeScopeId, storeScopeKey, subAccountId, month, usedPointUnits, usedPoints, limitPointUnits, limitPoints, unlimited, scopeStatus, storeStatus}`;**额度字段仅当月有意义**(历史月返回 null:历史生效额度只能按上位设计 §3.3 用审计重放,本接口不猜);排序 `usedPointUnits DESC, scopeId` 保证分页稳定;PLATFORM 查询写审计 `QUERY_MONTH_USAGE`。
- P3 不改 P1 DDL,无需重跑建表。尚未接入 P4 模型派发/补偿,不修改 Java/前端、真实配置、Docker 入口,不授权真实赠送。真实网关 ACK/状态/字段、单写隔离和人工证据流程仍属于上线验收门槛。 - P3 不改 P1 DDL,无需重跑建表。尚未接入 P4 模型派发/补偿,不修改 Java/前端、真实配置、Docker 入口,不授权真实赠送。真实网关 ACK/状态/字段、单写隔离和人工证据流程仍属于上线验收门槛。
## 13. P4 模型调用与结算细化(2026-09-23) ## 13. P4 模型调用与结算细化(2026-09-23)
......
#!/bin/sh
# 隔离 MySQL 实例:给 MySQL 专项用例提供真机语义(行锁、原子 UPSERT、CHECK)。
#
# 用法:
# scripts/p1_mysql_instance.sh start # 初始化(首次)并后台启动
# scripts/p1_mysql_instance.sh stop # 关闭
# scripts/p1_mysql_instance.sh status # 探活 + 打印回归命令
#
# 约束:socket 所在目录名必须保持 `computing-p1-mysql.*` 前缀,
# tests/test_account_foundation.py 用它把临时实例与业务库严格区分开;
# 实例只监听 unix socket(--skip-networking),不使用 3306。
set -eu
DIR="${P1_MYSQL_DIR:-/tmp/computing-p1-mysql.dev}"
MYSQLD="${P1_MYSQLD:-/usr/local/mysql/bin/mysqld}"
MYSQLADMIN="${P1_MYSQLADMIN:-/usr/local/mysql/bin/mysqladmin}"
SOCKET="$DIR/mysqld.sock"
BASEDIR="$(cd "$(dirname "$MYSQLD")/.." && pwd)"
start() {
if [ ! -d "$DIR/data/mysql" ]; then
echo "初始化数据目录 $DIR/data"
mkdir -p "$DIR/data"
"$MYSQLD" --no-defaults --initialize-insecure --datadir="$DIR/data" \
--basedir="$BASEDIR" --log-error="$DIR/init.log"
fi
if [ -S "$SOCKET" ] && "$MYSQLADMIN" --no-defaults --socket="$SOCKET" -uroot ping >/dev/null 2>&1; then
echo "已在运行:$SOCKET"
else
# 与 P0 要求一致:READ COMMITTED、UTC、严格 SQL 模式(MySQL >= 8.0.16)。
nohup "$MYSQLD" --no-defaults --datadir="$DIR/data" --basedir="$BASEDIR" \
--socket="$SOCKET" --skip-networking --skip-mysqlx \
--pid-file="$DIR/mysqld.pid" --log-error="$DIR/error.log" \
--transaction-isolation=READ-COMMITTED --default-time-zone=+00:00 \
--sql-mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION \
>/dev/null 2>&1 &
i=0
while [ "$i" -lt 60 ]; do
if "$MYSQLADMIN" --no-defaults --socket="$SOCKET" -uroot ping >/dev/null 2>&1; then break; fi
i=$((i + 1)); sleep 1
done
fi
"$MYSQLADMIN" --no-defaults --socket="$SOCKET" -uroot ping >/dev/null 2>&1 \
|| { echo "启动失败,见 $DIR/error.log"; exit 1; }
echo "就绪:$SOCKET"
echo "回归:python3 -m pytest --account-mysql-socket=$SOCKET -q --junitxml=/tmp/junit-mysql.xml"
}
stop() {
if [ -S "$SOCKET" ]; then
"$MYSQLADMIN" --no-defaults --socket="$SOCKET" -uroot shutdown || true
echo "已关闭 $SOCKET"
else
echo "未运行"
fi
}
status() {
if [ -S "$SOCKET" ] && "$MYSQLADMIN" --no-defaults --socket="$SOCKET" -uroot ping >/dev/null 2>&1; then
echo "运行中:$SOCKET"
echo "SQLite+MySQL 全量:python3 -m pytest --account-mysql-socket=$SOCKET -q --junitxml=/tmp/junit-mysql.xml"
else
echo "未运行(start 可启动)"
fi
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
status) status ;;
*) echo "用法:$0 start|stop|status" >&2; exit 2 ;;
esac
...@@ -9,7 +9,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) ...@@ -9,7 +9,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.account_models import Base from app.account_models import Base
HEADER = """-- P1 independent account schema; generated by scripts/render_account_ddl.py. HEADER = """-- Current independent account schema; generated by scripts/render_account_ddl.py.
-- Canonical output: scripts/sql/schema.sql (development baseline, not an upgrade).
-- Includes account hierarchy, scopes, monthly quotas and ledger indexes.
-- Target: a NEW, explicitly selected computing schema, MySQL >= 8.0.16. -- Target: a NEW, explicitly selected computing schema, MySQL >= 8.0.16.
-- Review and execute manually. Do not run on the existing SaaS schema. -- Review and execute manually. Do not run on the existing SaaS schema.
-- No CREATE DATABASE/USE, no account grants, no source data changes, no ID seed. -- No CREATE DATABASE/USE, no account grants, no source data changes, no ID seed.
......
# SQL 使用说明
系统处于开发阶段、尚未上线。**新建独立算力库只使用当前全量 `schema.sql`,不再按日期叠加历史增量。** 日常维护只需要关注本目录的两个 SQL 文件。
## 目录与目标库
| 路径 | 目标库 | 用途 |
| --- | --- | --- |
| [schema.sql](schema.sql) | 新建、空的独立算力库 | 当前 15 张表及全部索引、约束;唯一建表基线 |
| [check.sql](check.sql) | 当前独立算力库 | 只读检查账本、余额桶、调用关联、主体、员工月用量及号段 |
| [saas/source_precheck.sql](saas/source_precheck.sql) | 旧 SaaS 库 | 接入/迁移前的只读预检;不是网关初始化步骤 |
| [saas/binding_and_meiji_request_id.sql](saas/binding_and_meiji_request_id.sql) | SaaS 库 | 旧接入方案交付材料;实施 SaaS 对接时重新核对,不随网关建库执行 |
| [archive/](archive/) | 历史对应的库 | 旧基线、增量及检查脚本,原文保留供追溯;不参与当前初始化或测试 |
禁止递归批量执行整个目录。`saas/` 与 `archive/` 的脚本不是 `schema.sql` 的后续步骤。
## 新环境初始化
1. 在项目外准备独立数据库及运行账号,显式选择目标数据库。最低 MySQL 8.0.16;服务会使用 READ COMMITTED、UTC 和严格模式。
2. 在空的独立数据库执行 `schema.sql` 一次。脚本不包含建库、`USE`、授权、数据导入或号段初始化;现有表会让执行失败,不用 `--force` 跳过错误。
3. 准备号段。仅对于真正空的新安装,在开始发号前可显式执行下列语句;不使用 `INSERT IGNORE` 或覆盖式更新来掩盖已有状态:
```sql
INSERT INTO t_computing_id_segment (generator_key, next_id)
VALUES ('computing-global', 100000000000000000);
```
若涉及历史数据导入,必须停止旧分配器并继承其已提交高水位,不能使用上述空库种子,也不能用 `MAX(id)+1` 代替。
4. 执行 `check.sql`。然后按项目 README 配置外置文件、初始化管理凭证并启动服务;建表不等于完成凭证和网关配置。
已有开发数据库不会被这些文件自动升级、删除或重建。可以对确认不需要保留数据的环境重新建一个空库;要保留现有数据时,应依据其实际结构制定单独升级操作。本轮整理没有操作已有开发数据或业务库。
## 检查结果口径
- **带 `anomaly` 列**的结果集:表示一致性异常,在一致快照上应为空。
- 其他结果集:数据库信息、表清单、未终结调用、号段信息和授权状态,返回行不自动等于数据错误。
- 对账时暂停并发写入或使用一致快照,避免不同语句看到不同阶段的数据。空异常结果不代表源库与目标库迁移金额完全一致。
- 账户余额只核对 `sub_account_id IS NULL` 的账户桶流水;子账号各自核对自己的桶。
- 员工月用量只统计 EMPLOYEE 主体,按调用登记月归类;门店消费不产生员工月用量。
- 消费与流水按 `account_id + sub_account_id` 分组,防止不同账户的未分配桶相互抵消。
- 转账按来源、请求号、账户核对配对;相同请求号在不同来源下相互独立。
- 号段检查覆盖当前所有具有数值 `id` 的独立表,包括子账号、scope 和额度表;月用量表使用员工与月份联合主键,不参与发号。
## 开发期间如何变更
1. 修改 `app/account_models.py` 的结构定义。
2. 在项目根目录生成当前全量基线:
```bash
python scripts/render_account_ddl.py --output scripts/sql/schema.sql
```
3. 涉及账务或主体关系变化时,同步更新 `check.sql` 及相关契约。
4. MySQL 测试直接执行当前 `schema.sql`;基础检查测试与分账 E2E 执行交付的 `check.sql`,而非仅用 ORM 模拟其查询。
5. 未上线期间不再为每次结构修改增加一套日期基线和增量。正式上线后,冻结首个发布基线,再为需要保留数据的已发布数据库维护版本化增量。
本轮合并了早期检查和层级检查,校正余额桶、员工月用量、跨账户聚合及转账幂等域的口径;不改变运行服务的计费规则。
## 整理验证(2026-09-30)
- 确认当前全量 DDL 的结构语句与整理前的最新全量文件一致,仅更新说明与路径。
- 六份归档 SQL 逐文件核对,原文未变;当前测试和执行说明不再引用旧入口。
- SQLite + 专用临时 MySQL 全量回归:**2160 passed、27 skipped**(Python 3.9.6,200.36 秒)。目标 Python 3.12 尚未在本轮验证。
- 直接执行交付检查 SQL,覆盖正常分账、异常关联、跨账户桶差额及不同来源同请求号转账;未操作已有开发数据或业务库。
## 旧文件去向
| 原 `scripts/` 文件名 | 当前位置 |
| --- | --- |
| `20260929_independent_account_ddl.sql` | `schema.sql`,以后由 ORM 生成 |
| `20260923_account_source_precheck.sql` | `saas/source_precheck.sql` |
| `20260923_saas_binding_and_meiji_request_id.sql` | `saas/binding_and_meiji_request_id.sql` |
| `20260923_account_target_check.sql` | 原文放入 `archive/`,有效检查已合并进 `check.sql` |
| `20260929_account_hierarchy_target_check.sql` | 原文放入 `archive/`,有效检查已合并进 `check.sql` |
| `20260923_independent_account_ddl.sql` | `archive/` |
| `20260929_account_hierarchy_and_scopes_ddl.sql` | `archive/` |
| `20260922_computing_stage0_ddl.sql` | `archive/` |
| `reconciliation_check.sql` | `archive/` |
-- P6 hierarchy incremental DDL: account sub-accounts and store/employee scopes.
-- Generated alongside scripts/20260929_independent_account_ddl.sql (full schema,
-- models in app/account_models.py). Review and execute manually on the target
-- schema that already ran scripts/20260923_independent_account_ddl.sql.
-- No CREATE DATABASE/USE, no account grants, no data changes, no ID seed.
-- New tables are additive: no backfill required; existing call/consumption rows
-- keep NULL hierarchy snapshots (account bucket, no scope) which is the correct
-- pre-hierarchy semantics. t_computing_id_segment is untouched; the new tables
-- share the existing computing-global high water mark.
-- No IF NOT EXISTS: existing objects must stop execution; never continue with
-- --force. Retrying after partial failure requires inspecting the target, not
-- overwriting it. Order matters: new tables first, then ALTERs that reference them.
-- Foreign keys added to t_computing_credential and t_computing_point_record carry
-- explicit names (fk_..._sub_account / fk_..._scope); a fresh install from the
-- full DDL instead gets engine-generated names for the same constraints, which
-- is cosmetic only.
-- Rollback: this script is forward-only. Before any hierarchy rows exist, the
-- added columns could be dropped and the new tables discarded; once IDs or
-- transfers exist, reconcile first and never rewind IDs.
-- Design: docs/account-hierarchy-and-scope-limits-design.md (v0.2); contract:
-- docs/p0-contract-freeze.md §2.10.
CREATE TABLE t_computing_sub_account (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
remark VARCHAR(500),
status ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
balance_point_units BIGINT NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_sub_account_name UNIQUE (account_id, name),
FOREIGN KEY(account_id) REFERENCES t_computing_account (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_sub_account_created ON t_computing_sub_account (account_id, create_time, id);
CREATE TABLE t_computing_scope (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
sub_account_id BIGINT UNSIGNED,
scope_type ENUM('STORE','EMPLOYEE') NOT NULL,
scope_key VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
parent_scope_id BIGINT UNSIGNED,
status ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_scope_identity UNIQUE (scope_type, scope_key),
CONSTRAINT ck_computing_scope_shape CHECK ((scope_type = 'STORE' AND parent_scope_id IS NULL) OR (scope_type = 'EMPLOYEE' AND parent_scope_id IS NOT NULL AND sub_account_id IS NULL)),
CONSTRAINT fk_computing_scope_account FOREIGN KEY(account_id) REFERENCES t_computing_account (id),
CONSTRAINT fk_computing_scope_sub_account FOREIGN KEY(sub_account_id) REFERENCES t_computing_sub_account (id),
CONSTRAINT fk_computing_scope_parent FOREIGN KEY(parent_scope_id) REFERENCES t_computing_scope (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_scope_parent ON t_computing_scope (parent_scope_id);
CREATE INDEX idx_computing_scope_sub_account ON t_computing_scope (sub_account_id, scope_type, status);
CREATE TABLE t_computing_scope_quota (
id BIGINT UNSIGNED NOT NULL,
scope_id BIGINT UNSIGNED NOT NULL,
store_scope_id BIGINT UNSIGNED NOT NULL,
monthly_quota_point_units BIGINT,
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_scope_quota_pair UNIQUE (scope_id, store_scope_id),
CONSTRAINT ck_computing_scope_quota_nonnegative CHECK (monthly_quota_point_units IS NULL OR monthly_quota_point_units >= 0),
CONSTRAINT fk_computing_scope_quota_scope FOREIGN KEY(scope_id) REFERENCES t_computing_scope (id),
CONSTRAINT fk_computing_scope_quota_store FOREIGN KEY(store_scope_id) REFERENCES t_computing_scope (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_scope_month_usage (
scope_id BIGINT UNSIGNED NOT NULL,
month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
used_point_units BIGINT NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (scope_id, month),
CONSTRAINT fk_computing_scope_month_usage_scope FOREIGN KEY(scope_id) REFERENCES t_computing_scope (id),
CONSTRAINT ck_computing_scope_month_usage_nonnegative CHECK (used_point_units >= 0)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
ALTER TABLE t_computing_management_request
MODIFY COLUMN operation_type ENUM('PROVISION_ACCOUNT','ISSUE_CALL_KEY','ACTIVATE_CREDENTIAL','REVOKE_CREDENTIAL','GRANT_CLIENT','REVOKE_CLIENT','SET_ACCOUNT_STATUS','RECONCILE','CREATE_SUB_ACCOUNT','SET_SUB_ACCOUNT_STATUS','TRANSFER','BIND_SCOPE','SET_SCOPE_STATUS','MOVE_STORE','MOVE_EMPLOYEE','SET_SCOPE_QUOTA') NOT NULL;
ALTER TABLE t_computing_credential
ADD COLUMN sub_account_id BIGINT UNSIGNED AFTER account_id,
ADD COLUMN scope_id BIGINT UNSIGNED AFTER sub_account_id,
DROP CHECK ck_computing_credential_scope,
ADD CONSTRAINT ck_computing_credential_scope CHECK ((category = 'CALL' AND account_id IS NOT NULL AND issued_by IS NOT NULL AND NOT (sub_account_id IS NOT NULL AND scope_id IS NOT NULL)) OR (category IN ('INTEGRATION', 'PLATFORM') AND account_id IS NULL AND sub_account_id IS NULL AND scope_id IS NULL)),
ADD CONSTRAINT fk_computing_credential_sub_account FOREIGN KEY(sub_account_id) REFERENCES t_computing_sub_account (id),
ADD CONSTRAINT fk_computing_credential_scope_ref FOREIGN KEY(scope_id) REFERENCES t_computing_scope (id);
ALTER TABLE t_computing_call
ADD COLUMN sub_account_id BIGINT UNSIGNED,
ADD COLUMN scope_id BIGINT UNSIGNED,
ADD COLUMN store_scope_id BIGINT UNSIGNED,
ADD COLUMN quota_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin;
ALTER TABLE t_computing_consumption
ADD COLUMN sub_account_id BIGINT UNSIGNED,
ADD COLUMN scope_id BIGINT UNSIGNED,
ADD COLUMN store_scope_id BIGINT UNSIGNED;
ALTER TABLE t_computing_point_record
ADD COLUMN sub_account_id BIGINT UNSIGNED AFTER consumption_record_id,
MODIFY COLUMN type ENUM('GIFT','CONSUME','TRANSFER_OUT','TRANSFER_IN') NOT NULL,
DROP CHECK ck_computing_point_record_source,
ADD CONSTRAINT ck_computing_point_record_source CHECK ((type = 'GIFT' AND point_units > 0 AND call_id IS NULL AND consumption_record_id IS NULL AND sub_account_id IS NULL AND (legacy_record = 1 OR gift_id IS NOT NULL)) OR (type = 'CONSUME' AND point_units < 0 AND gift_id IS NULL AND consumption_record_id IS NOT NULL AND (legacy_record = 1 OR call_id IS NOT NULL)) OR (type = 'TRANSFER_OUT' AND point_units < 0 AND gift_id IS NULL AND call_id IS NULL AND consumption_record_id IS NULL) OR (type = 'TRANSFER_IN' AND point_units > 0 AND gift_id IS NULL AND call_id IS NULL AND consumption_record_id IS NULL)),
ADD CONSTRAINT fk_computing_point_record_sub_account FOREIGN KEY(sub_account_id) REFERENCES t_computing_sub_account (id);
CREATE INDEX idx_computing_point_record_bucket ON t_computing_point_record (account_id, sub_account_id, type);
-- 段 3e(2026-09-30):分账报表索引。实测(隔离 MySQL 8.0.43;单账户 10 万行、门店选择性 1%、员工 0.05%、
-- 桶 80%,见 docs/account-hierarchy-and-scope-limits-design.md §8):
-- 门店账 LIMIT 20:无维度索引时顺 account+create_time 逆序读 3,983 行 / 2.80ms;加索引后 20 行 / 0.02ms
-- 员工账 LIMIT 20:无索引读 40,000 行 / 18.8ms;加索引后 20 行 / 0.01ms
-- 子账号桶(占行数 80%,现有索引顺扫即命中):0.73ms → 0.30ms,收益不足抵 44B/行写放大 → 不加
-- 月账单页:瓶颈在 ORDER BY used 排序而非 scope 扫描(6,300 scope 表全扫 1.65ms),scope 表比
-- consumption 小两个数量级 → 不加;scope 数到 10^4 量级或月账单变慢时再评估
-- 代价:每索引 ≈44B/行(30 万行 ≈12.6MB);call/consumption 各 2 条,插入与结算更新各多写 4 项。
CREATE INDEX idx_computing_call_store_scope ON t_computing_call (account_id, store_scope_id, create_time, id);
CREATE INDEX idx_computing_call_scope_subject ON t_computing_call (account_id, scope_id, create_time, id);
CREATE INDEX idx_computing_consumption_store_scope ON t_computing_consumption (account_id, store_scope_id, create_time, id);
CREATE INDEX idx_computing_consumption_scope_subject ON t_computing_consumption (account_id, scope_id, create_time, id);
-- P6 hierarchy target checks: read-only, run manually on the independent schema
-- AFTER scripts/20260929_account_hierarchy_and_scopes_ddl.sql has been applied.
-- Does not replace 20260923_account_target_check.sql (frozen, already executed);
-- run both. Empty anomaly results do not prove source-to-target equality.
-- Sections returning anomaly rows must be reviewed before relying on the data.
SELECT DATABASE() AS target_database, VERSION() AS mysql_version,
@@session.transaction_isolation AS isolation_level, @@session.time_zone AS time_zone;
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (
't_computing_sub_account', 't_computing_scope',
't_computing_scope_quota', 't_computing_scope_month_usage'
)
ORDER BY table_name;
-- 1. Bucket identity: account balance equals the sum of its account-bucket
-- records (sub_account_id IS NULL), exactly one row per account when any
-- record exists; residual non-zero is an anomaly.
SELECT a.id AS account_id, a.balance_point_units,
COALESCE(p.net_units, 0) AS account_bucket_net_units,
a.balance_point_units - COALESCE(p.net_units, 0) AS residual_units
FROM t_computing_account a
LEFT JOIN (
SELECT account_id, SUM(point_units) AS net_units
FROM t_computing_point_record
WHERE sub_account_id IS NULL
GROUP BY account_id
) p ON p.account_id = a.id
WHERE a.balance_point_units <> COALESCE(p.net_units, 0);
-- 2. Bucket identity: each sub-account balance equals its bucket sum.
SELECT s.id AS sub_account_id, s.account_id, s.balance_point_units,
COALESCE(p.net_units, 0) AS sub_bucket_net_units,
s.balance_point_units - COALESCE(p.net_units, 0) AS residual_units
FROM t_computing_sub_account s
LEFT JOIN (
SELECT sub_account_id, SUM(point_units) AS net_units
FROM t_computing_point_record
WHERE sub_account_id IS NOT NULL
GROUP BY sub_account_id
) p ON p.sub_account_id = s.id
WHERE s.balance_point_units <> COALESCE(p.net_units, 0);
-- 3. Transfer conservation: every transfer requestId has exactly one OUT and
-- one IN, equal magnitude (net zero), one row on the account bucket and one
-- on a sub-account bucket of the same account.
SELECT 'transfer_pairing' AS anomaly, request_id, account_id,
SUM(type = 'TRANSFER_OUT') AS out_rows, SUM(type = 'TRANSFER_IN') AS in_rows,
SUM(point_units) AS net_units,
SUM(sub_account_id IS NULL) AS account_bucket_rows
FROM t_computing_point_record
WHERE type IN ('TRANSFER_OUT', 'TRANSFER_IN')
GROUP BY request_id, account_id
HAVING out_rows <> 1 OR in_rows <> 1 OR net_units <> 0 OR account_bucket_rows <> 1;
SELECT 'transfer_cross_account' AS anomaly, o.request_id, o.account_id AS out_account, i.account_id AS in_account
FROM t_computing_point_record o
JOIN t_computing_point_record i
ON i.request_id = o.request_id AND i.type = 'TRANSFER_IN' AND o.type = 'TRANSFER_OUT'
WHERE o.account_id <> i.account_id;
-- 4. GIFT records must stay on the account bucket.
SELECT 'gift_not_account_bucket' AS anomaly, p.id, p.gift_id
FROM t_computing_point_record p
WHERE p.type = 'GIFT' AND p.sub_account_id IS NOT NULL;
-- 5. Scope shape: employees must point at a STORE of the same account; stores
-- must not carry a parent; employee rows must not carry sub_account_id.
-- ACTIVE is deliberately NOT checked (a disabled store with live employees
-- is a legal steady state).
SELECT 'scope_shape' AS anomaly, s.id, s.scope_type, s.parent_scope_id, s.account_id
FROM t_computing_scope s
LEFT JOIN t_computing_scope p ON p.id = s.parent_scope_id
WHERE (s.scope_type = 'EMPLOYEE' AND (s.parent_scope_id IS NULL OR p.id IS NULL
OR p.scope_type <> 'STORE' OR p.account_id <> s.account_id
OR s.sub_account_id IS NOT NULL))
OR (s.scope_type = 'STORE' AND s.parent_scope_id IS NOT NULL);
-- 6. Store sub_account binding must belong to the same account.
SELECT 'scope_sub_account_mismatch' AS anomaly, s.id, s.scope_key, s.account_id, sa.account_id AS bound_account
FROM t_computing_scope s
JOIN t_computing_sub_account sa ON sa.id = s.sub_account_id
WHERE s.scope_type = 'STORE' AND sa.account_id <> s.account_id;
-- 7. scope_quota rows: employee x store of the same account.
SELECT 'scope_quota_shape' AS anomaly, q.id, q.scope_id, q.store_scope_id
FROM t_computing_scope_quota q
JOIN t_computing_scope e ON e.id = q.scope_id
JOIN t_computing_scope st ON st.id = q.store_scope_id
WHERE e.scope_type <> 'EMPLOYEE' OR st.scope_type <> 'STORE'
OR e.account_id <> st.account_id;
-- 8. Credential hierarchy: only CALL keys carry a subject; the subject must
-- belong to the credential's own account; subject columns are exclusive.
SELECT 'credential_subject_mismatch' AS anomaly, c.id, c.account_id, c.sub_account_id, c.scope_id
FROM t_computing_credential c
LEFT JOIN t_computing_sub_account sa ON sa.id = c.sub_account_id
LEFT JOIN t_computing_scope sc ON sc.id = c.scope_id
WHERE c.category <> 'CALL' AND (c.sub_account_id IS NOT NULL OR c.scope_id IS NOT NULL)
OR (c.sub_account_id IS NOT NULL AND c.scope_id IS NOT NULL)
OR (c.sub_account_id IS NOT NULL AND sa.account_id <> c.account_id)
OR (c.scope_id IS NOT NULL AND sc.account_id <> c.account_id);
-- 9. Call snapshots equal consumption snapshots for settled calls.
SELECT 'call_consumption_snapshot_mismatch' AS anomaly, c.id, c.account_id
FROM t_computing_call c
JOIN t_computing_consumption r ON r.id = c.consumption_record_id
WHERE c.billing_status = 'SUCCESS' AND NOT (
r.sub_account_id <=> c.sub_account_id
AND r.scope_id <=> c.scope_id
AND r.store_scope_id <=> c.store_scope_id
);
-- 10. Consume point records land on the same bucket as their consumption.
SELECT 'consume_bucket_mismatch' AS anomaly, p.id, p.consumption_record_id
FROM t_computing_point_record p
JOIN t_computing_consumption r ON r.id = p.consumption_record_id
WHERE p.type = 'CONSUME' AND NOT (p.sub_account_id <=> r.sub_account_id);
-- 11. Monthly usage: scope_month_usage equals the settled consumption grouped
-- by the registration-month snapshot carried on the call.
SELECT 'month_usage_mismatch' AS anomaly, u.scope_id, u.month,
u.used_point_units, COALESCE(agg.units, 0) AS aggregated_units
FROM t_computing_scope_month_usage u
LEFT JOIN (
SELECT c.scope_id, c.quota_month, SUM(r.consumed_point_units) AS units
FROM t_computing_consumption r
JOIN t_computing_call c ON c.id = r.call_id
WHERE r.settlement_status = 'SUCCESS' AND c.scope_id IS NOT NULL
GROUP BY c.scope_id, c.quota_month
) agg ON agg.scope_id = u.scope_id AND agg.quota_month = u.month
WHERE u.used_point_units <> COALESCE(agg.units, 0);
SELECT 'month_usage_orphan' AS anomaly, agg.scope_id, agg.quota_month, agg.units
FROM (
SELECT c.scope_id, c.quota_month, SUM(r.consumed_point_units) AS units
FROM t_computing_consumption r
JOIN t_computing_call c ON c.id = r.call_id
WHERE r.settlement_status = 'SUCCESS' AND c.scope_id IS NOT NULL
GROUP BY c.scope_id, c.quota_month
) agg
LEFT JOIN t_computing_scope_month_usage u
ON u.scope_id = agg.scope_id AND u.month = agg.quota_month
WHERE u.scope_id IS NULL;
-- 12. Every registration carries its business month (段 3 修正:quota_month 是**登记月**,
-- 所有调用都有;它只在员工主体上被读来累加月用量,账户级/子账号级调用留着不参与统计)。
-- 上一版规则把「无主体却带 quota_month」当异常,会对每一笔账户级调用误报,已作废。
SELECT 'quota_month_shape' AS anomaly, c.id, c.account_id, c.scope_id, c.store_scope_id, c.quota_month
FROM t_computing_call c
WHERE c.quota_month IS NULL
OR c.quota_month NOT REGEXP '^[0-9]{4}-(0[1-9]|1[0-2])$';
-- 13. 分账维度形状(段 3a):主体快照成对出现——门店/员工调用 scope_id 与 store_scope_id
-- 都有,账户级/子账号级调用两者都无;否则门店聚合会漏行(§8 恒等式的前提)。
SELECT 'ledger_snapshot_pairing' AS anomaly, c.id, c.account_id, c.sub_account_id, c.scope_id, c.store_scope_id
FROM t_computing_call c
WHERE (c.scope_id IS NULL) <> (c.store_scope_id IS NULL);
-- 14. 分账快照的桶必须属于同一账户(历史改派允许与 scope 当前绑定不同,但不允许跨账户)。
SELECT 'ledger_bucket_cross_account' AS anomaly, c.id, c.account_id, c.sub_account_id
FROM t_computing_call c
JOIN t_computing_sub_account sa ON sa.id = c.sub_account_id
WHERE sa.account_id <> c.account_id;
-- 15. 分账聚合 = 桶流水(段 3d):消费按 sub_account_id 聚合必须与 CONSUME 流水一致;
-- 账户桶(NULL)与子账号桶各自守恒。门店/员工维度不产生独立流水,由 #9 快照一致
-- 与 #11 月用量核对覆盖。
SELECT 'consumption_bucket_mismatch' AS anomaly, cr.bucket_id, cr.units AS consumption_units, pr.units AS point_record_units
FROM (SELECT COALESCE(sub_account_id, 0) AS bucket_id, SUM(consumed_point_units) AS units
FROM t_computing_consumption WHERE settlement_status = 'SUCCESS'
GROUP BY COALESCE(sub_account_id, 0)) cr
LEFT JOIN (SELECT COALESCE(sub_account_id, 0) AS bucket_id, SUM(-point_units) AS units
FROM t_computing_point_record WHERE type = 'CONSUME'
GROUP BY COALESCE(sub_account_id, 0)) pr ON pr.bucket_id = cr.bucket_id
WHERE COALESCE(pr.units, 0) <> cr.units
UNION ALL
SELECT 'consumption_bucket_orphan', pr.bucket_id, 0, pr.units
FROM (SELECT COALESCE(sub_account_id, 0) AS bucket_id, SUM(-point_units) AS units
FROM t_computing_point_record WHERE type = 'CONSUME'
GROUP BY COALESCE(sub_account_id, 0)) pr
LEFT JOIN (SELECT COALESCE(sub_account_id, 0) AS bucket_id, SUM(consumed_point_units) AS units
FROM t_computing_consumption WHERE settlement_status = 'SUCCESS'
GROUP BY COALESCE(sub_account_id, 0)) cr ON cr.bucket_id = pr.bucket_id
WHERE cr.bucket_id IS NULL;
# 历史 SQL,仅供追溯
这些文件原文保留,不再参与新环境初始化、当前数据库检查或自动化测试。文件头中的相对路径、执行顺序和阶段说明属于历史上下文,不应作为当前操作指引。
新库使用上一级的 [schema.sql](../schema.sql),当前独立库检查使用 [check.sql](../check.sql),完整说明见 [SQL 使用说明](../README.md)。
| 文件 | 历史用途 |
| --- | --- |
| `20260922_computing_stage0_ddl.sql` | 旧 SaaS 共享库增量 |
| `reconciliation_check.sql` | 旧 SaaS 共享库对账 |
| `20260923_independent_account_ddl.sql` | 11 张表的旧独立库基线 |
| `20260929_account_hierarchy_and_scopes_ddl.sql` | 旧独立库基线上的层级增量 |
| `20260923_account_target_check.sql` | 账户层级引入前的检查口径 |
| `20260929_account_hierarchy_target_check.sql` | 层级检查历史版本,当前合并版已校正部分口径 |
-- Current independent-account checks: read-only, MySQL >= 8.0.16.
-- Run on the explicitly selected computing schema after schema.sql and ID setup.
-- This replaces the separate baseline/hierarchy checks for current development.
-- No data writes, schema changes, ID reset, credential grants or gateway calls.
-- Result sets with an anomaly column must be empty for a consistent snapshot.
-- Other result sets are inventory/pending work/access status, not automatic failures.
-- Run without concurrent writes for a consistent cross-statement reconciliation.
-- Empty anomaly results do not establish source-to-target migration equality.
-- See scripts/sql/README.md for database scope and execution order.
-- A. Base ledger links, pending work, credentials and ID high-water marks.
SELECT DATABASE() AS target_database, VERSION() AS mysql_version,
@@session.transaction_isolation AS isolation_level, @@session.time_zone AS time_zone;
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 't_computing_%'
ORDER BY table_name;
SELECT 'call_consumption_mismatch' AS anomaly, c.id, c.account_id, c.consumption_record_id
FROM t_computing_call c
LEFT JOIN t_computing_consumption r ON r.id = c.consumption_record_id
WHERE c.billing_status = 'SUCCESS' AND (
r.id IS NULL OR NOT (r.call_id <=> c.id) OR NOT (r.account_id <=> c.account_id)
OR NOT (r.client_id <=> c.client_id) OR NOT (r.settlement_status <=> 'SUCCESS')
OR NOT (r.consumed_quota <=> c.consumed_quota)
OR NOT (r.point_units_per_quota <=> c.point_units_per_quota)
OR NOT (r.consumed_point_units <=> c.consumed_point_units)
);
SELECT 'consumption_call_mismatch' AS anomaly, r.id, r.account_id, r.call_id
FROM t_computing_consumption r
LEFT JOIN t_computing_call c ON c.id = r.call_id
WHERE r.settlement_status = 'SUCCESS' AND r.call_id IS NOT NULL AND (
c.id IS NULL OR NOT (c.consumption_record_id <=> r.id)
OR NOT (c.account_id <=> r.account_id) OR NOT (c.client_id <=> r.client_id)
OR NOT (c.billing_status <=> 'SUCCESS') OR NOT (c.settled_flag <=> 1)
OR NOT (c.consumed_quota <=> r.consumed_quota)
OR NOT (c.point_units_per_quota <=> r.point_units_per_quota)
OR NOT (c.consumed_point_units <=> r.consumed_point_units)
);
SELECT 'consumption_point_mismatch' AS anomaly, r.id, r.consumed_point_units, p.id AS point_record_id
FROM t_computing_consumption r
LEFT JOIN t_computing_point_record p ON p.consumption_record_id = r.id
WHERE r.settlement_status = 'SUCCESS' AND (
(r.consumed_point_units > 0 AND (p.id IS NULL OR p.point_units <> -r.consumed_point_units))
OR (r.consumed_point_units = 0 AND p.id IS NOT NULL)
);
SELECT 'point_consumption_mismatch' AS anomaly, p.id, p.call_id, p.consumption_record_id
FROM t_computing_point_record p
LEFT JOIN t_computing_consumption r ON r.id = p.consumption_record_id
WHERE p.type = 'CONSUME' AND (
r.id IS NULL OR NOT (r.settlement_status <=> 'SUCCESS')
OR NOT (p.account_id <=> r.account_id) OR NOT (p.client_id <=> r.client_id)
OR NOT (p.call_id <=> r.call_id) OR NOT (p.point_units <=> -r.consumed_point_units)
);
SELECT 'gift_point_mismatch' AS anomaly, g.id, g.account_id, p.id AS point_record_id
FROM t_computing_gift g
LEFT JOIN t_computing_point_record p ON p.gift_id = g.id
WHERE g.status = 'SUCCESS' AND (p.id IS NULL OR p.point_units <> g.credited_point_units);
-- Reverse direction: a written GIFT point record must sit on a SUCCESS gift
-- with the credited amount; records anchored to non-terminal gifts are anomalies.
SELECT 'gift_point_record_terminal_mismatch' AS anomaly, p.id, p.gift_id, g.status
FROM t_computing_point_record p
LEFT JOIN t_computing_gift g ON g.id = p.gift_id
WHERE p.type = 'GIFT' AND p.gift_id IS NOT NULL AND p.legacy_record = 0
AND (g.id IS NULL OR g.status <> 'SUCCESS' OR p.point_units <> g.credited_point_units);
SELECT 'gift_gate_mismatch' AS anomaly, a.id, a.gift_gate, g.status
FROM t_computing_account a
LEFT JOIN t_computing_gift g ON g.id = a.gift_gate
WHERE a.gift_gate IS NOT NULL AND (g.id IS NULL OR g.account_id <> a.id OR g.status = 'SUCCESS');
SELECT id, account_id, billing_status, execution_status
FROM t_computing_call WHERE billing_status NOT IN ('SUCCESS', 'FAILED');
SELECT generator_key, next_id FROM t_computing_id_segment;
SELECT 'missing_generator' AS anomaly
WHERE NOT EXISTS (SELECT 1 FROM t_computing_id_segment WHERE generator_key = 'computing-global');
-- Historical IDs at or above the high water mark would collide with future allocations.
SELECT 'high_water_collision' AS anomaly, t.table_name, t.max_id, s.next_id
FROM (
SELECT 't_computing_account' AS table_name, MAX(id) AS max_id FROM t_computing_account
UNION ALL SELECT 't_computing_credential', MAX(id) FROM t_computing_credential
UNION ALL SELECT 't_computing_gift', MAX(id) FROM t_computing_gift
UNION ALL SELECT 't_computing_call', MAX(id) FROM t_computing_call
UNION ALL SELECT 't_computing_consumption', MAX(id) FROM t_computing_consumption
UNION ALL SELECT 't_computing_point_record', MAX(id) FROM t_computing_point_record
UNION ALL SELECT 't_computing_management_request', MAX(id) FROM t_computing_management_request
UNION ALL SELECT 't_computing_operation_audit', MAX(id) FROM t_computing_operation_audit
UNION ALL SELECT 't_computing_sub_account', MAX(id) FROM t_computing_sub_account
UNION ALL SELECT 't_computing_scope', MAX(id) FROM t_computing_scope
UNION ALL SELECT 't_computing_scope_quota', MAX(id) FROM t_computing_scope_quota
) t
JOIN t_computing_id_segment s ON s.generator_key = 'computing-global'
WHERE t.max_id IS NOT NULL AND t.max_id >= s.next_id;
SELECT c.id, c.category
FROM t_computing_credential c
LEFT JOIN t_computing_account_client a ON a.account_id = c.account_id AND a.client_id = c.client_id
WHERE c.category = 'CALL' AND (a.account_id IS NULL OR a.status <> 'AUTHORIZED');
-- B. Current hierarchy checks (section numbers retain the design references).
-- 1. Bucket identity: account balance equals the sum of its account-bucket
-- records (sub_account_id IS NULL), exactly one row per account when any
-- record exists; residual non-zero is an anomaly.
SELECT 'account_balance_mismatch' AS anomaly, a.id AS account_id, a.balance_point_units,
COALESCE(p.net_units, 0) AS account_bucket_net_units,
a.balance_point_units - COALESCE(p.net_units, 0) AS residual_units
FROM t_computing_account a
LEFT JOIN (
SELECT account_id, SUM(point_units) AS net_units
FROM t_computing_point_record
WHERE sub_account_id IS NULL
GROUP BY account_id
) p ON p.account_id = a.id
WHERE a.balance_point_units <> COALESCE(p.net_units, 0);
-- 2. Bucket identity: each sub-account balance equals its bucket sum.
SELECT 'sub_account_balance_mismatch' AS anomaly, s.id AS sub_account_id, s.account_id, s.balance_point_units,
COALESCE(p.net_units, 0) AS sub_bucket_net_units,
s.balance_point_units - COALESCE(p.net_units, 0) AS residual_units
FROM t_computing_sub_account s
LEFT JOIN (
SELECT sub_account_id, SUM(point_units) AS net_units
FROM t_computing_point_record
WHERE sub_account_id IS NOT NULL
GROUP BY sub_account_id
) p ON p.sub_account_id = s.id
WHERE s.balance_point_units <> COALESCE(p.net_units, 0);
-- 3. Transfer conservation: every transfer requestId has exactly one OUT and
-- one IN, equal magnitude (net zero), one row on the account bucket and one
-- on a sub-account bucket of the same account.
SELECT 'transfer_pairing' AS anomaly, client_id, request_id, account_id,
SUM(type = 'TRANSFER_OUT') AS out_rows, SUM(type = 'TRANSFER_IN') AS in_rows,
SUM(point_units) AS net_units,
SUM(sub_account_id IS NULL) AS account_bucket_rows
FROM t_computing_point_record
WHERE type IN ('TRANSFER_OUT', 'TRANSFER_IN')
GROUP BY client_id, request_id, account_id
HAVING out_rows <> 1 OR in_rows <> 1 OR net_units <> 0 OR account_bucket_rows <> 1;
SELECT 'transfer_cross_account' AS anomaly, o.request_id, o.account_id AS out_account, i.account_id AS in_account
FROM t_computing_point_record o
JOIN t_computing_point_record i
ON i.client_id = o.client_id AND i.request_id = o.request_id AND i.type = 'TRANSFER_IN' AND o.type = 'TRANSFER_OUT'
WHERE o.account_id <> i.account_id;
-- 4. GIFT records must stay on the account bucket.
SELECT 'gift_not_account_bucket' AS anomaly, p.id, p.gift_id
FROM t_computing_point_record p
WHERE p.type = 'GIFT' AND p.sub_account_id IS NOT NULL;
-- 5. Scope shape: employees must point at a STORE of the same account; stores
-- must not carry a parent; employee rows must not carry sub_account_id.
-- ACTIVE is deliberately NOT checked (a disabled store with live employees
-- is a legal steady state).
SELECT 'scope_shape' AS anomaly, s.id, s.scope_type, s.parent_scope_id, s.account_id
FROM t_computing_scope s
LEFT JOIN t_computing_scope p ON p.id = s.parent_scope_id
WHERE (s.scope_type = 'EMPLOYEE' AND (s.parent_scope_id IS NULL OR p.id IS NULL
OR p.scope_type <> 'STORE' OR p.account_id <> s.account_id
OR s.sub_account_id IS NOT NULL))
OR (s.scope_type = 'STORE' AND s.parent_scope_id IS NOT NULL);
-- 6. Store sub_account binding must belong to the same account.
SELECT 'scope_sub_account_mismatch' AS anomaly, s.id, s.scope_key, s.account_id, sa.account_id AS bound_account
FROM t_computing_scope s
JOIN t_computing_sub_account sa ON sa.id = s.sub_account_id
WHERE s.scope_type = 'STORE' AND sa.account_id <> s.account_id;
-- 7. scope_quota rows: employee x store of the same account.
SELECT 'scope_quota_shape' AS anomaly, q.id, q.scope_id, q.store_scope_id
FROM t_computing_scope_quota q
JOIN t_computing_scope e ON e.id = q.scope_id
JOIN t_computing_scope st ON st.id = q.store_scope_id
WHERE e.scope_type <> 'EMPLOYEE' OR st.scope_type <> 'STORE'
OR e.account_id <> st.account_id;
-- 8. Credential hierarchy: only CALL keys carry a subject; the subject must
-- belong to the credential's own account; subject columns are exclusive.
SELECT 'credential_subject_mismatch' AS anomaly, c.id, c.account_id, c.sub_account_id, c.scope_id
FROM t_computing_credential c
LEFT JOIN t_computing_sub_account sa ON sa.id = c.sub_account_id
LEFT JOIN t_computing_scope sc ON sc.id = c.scope_id
WHERE c.category <> 'CALL' AND (c.sub_account_id IS NOT NULL OR c.scope_id IS NOT NULL)
OR (c.sub_account_id IS NOT NULL AND c.scope_id IS NOT NULL)
OR (c.sub_account_id IS NOT NULL AND sa.account_id <> c.account_id)
OR (c.scope_id IS NOT NULL AND sc.account_id <> c.account_id);
-- 9. Call snapshots equal consumption snapshots for settled calls.
SELECT 'call_consumption_snapshot_mismatch' AS anomaly, c.id, c.account_id
FROM t_computing_call c
JOIN t_computing_consumption r ON r.id = c.consumption_record_id
WHERE c.billing_status = 'SUCCESS' AND NOT (
r.sub_account_id <=> c.sub_account_id
AND r.scope_id <=> c.scope_id
AND r.store_scope_id <=> c.store_scope_id
);
-- 10. Consume point records land on the same bucket as their consumption.
SELECT 'consume_bucket_mismatch' AS anomaly, p.id, p.consumption_record_id
FROM t_computing_point_record p
JOIN t_computing_consumption r ON r.id = p.consumption_record_id
WHERE p.type = 'CONSUME' AND NOT (p.sub_account_id <=> r.sub_account_id);
-- 11. Monthly usage: scope_month_usage equals the settled consumption grouped
-- by the registration-month snapshot carried on the call.
SELECT 'month_usage_mismatch' AS anomaly, u.scope_id, u.month,
u.used_point_units, COALESCE(agg.units, 0) AS aggregated_units
FROM t_computing_scope_month_usage u
LEFT JOIN (
SELECT c.scope_id, c.quota_month, SUM(r.consumed_point_units) AS units
FROM t_computing_consumption r
JOIN t_computing_call c ON c.id = r.call_id
JOIN t_computing_scope e ON e.id = c.scope_id AND e.scope_type = 'EMPLOYEE'
WHERE r.settlement_status = 'SUCCESS' AND c.scope_id IS NOT NULL
GROUP BY c.scope_id, c.quota_month
) agg ON agg.scope_id = u.scope_id AND agg.quota_month = u.month
WHERE u.used_point_units <> COALESCE(agg.units, 0);
SELECT 'month_usage_orphan' AS anomaly, agg.scope_id, agg.quota_month, agg.units
FROM (
SELECT c.scope_id, c.quota_month, SUM(r.consumed_point_units) AS units
FROM t_computing_consumption r
JOIN t_computing_call c ON c.id = r.call_id
JOIN t_computing_scope e ON e.id = c.scope_id AND e.scope_type = 'EMPLOYEE'
WHERE r.settlement_status = 'SUCCESS' AND c.scope_id IS NOT NULL
GROUP BY c.scope_id, c.quota_month
) agg
LEFT JOIN t_computing_scope_month_usage u
ON u.scope_id = agg.scope_id AND u.month = agg.quota_month
WHERE u.scope_id IS NULL;
-- 12. Every registration carries its business month (段 3 修正:quota_month 是**登记月**,
-- 所有调用都有;它只在员工主体上被读来累加月用量,账户级/子账号级调用留着不参与统计)。
-- 上一版规则把「无主体却带 quota_month」当异常,会对每一笔账户级调用误报,已作废。
SELECT 'quota_month_shape' AS anomaly, c.id, c.account_id, c.scope_id, c.store_scope_id, c.quota_month
FROM t_computing_call c
WHERE c.quota_month IS NULL
OR c.quota_month NOT REGEXP '^[0-9]{4}-(0[1-9]|1[0-2])$';
-- 13. 分账维度形状(段 3a):主体快照成对出现——门店/员工调用 scope_id 与 store_scope_id
-- 都有,账户级/子账号级调用两者都无;否则门店聚合会漏行(§8 恒等式的前提)。
SELECT 'ledger_snapshot_pairing' AS anomaly, c.id, c.account_id, c.sub_account_id, c.scope_id, c.store_scope_id
FROM t_computing_call c
WHERE (c.scope_id IS NULL) <> (c.store_scope_id IS NULL);
-- 14. 分账快照的桶必须属于同一账户(历史改派允许与 scope 当前绑定不同,但不允许跨账户)。
SELECT 'ledger_bucket_cross_account' AS anomaly, c.id, c.account_id, c.sub_account_id
FROM t_computing_call c
JOIN t_computing_sub_account sa ON sa.id = c.sub_account_id
WHERE sa.account_id <> c.account_id;
-- 15. 分账聚合 = 桶流水(段 3d):消费按 sub_account_id 聚合必须与 CONSUME 流水一致;
-- 账户桶(NULL)与子账号桶各自守恒。门店/员工维度不产生独立流水,由 #9 快照一致
-- 与 #11 月用量核对覆盖。
SELECT 'consumption_bucket_mismatch' AS anomaly, cr.account_id, cr.bucket_id, cr.units AS consumption_units, pr.units AS point_record_units
FROM (SELECT account_id, COALESCE(sub_account_id, 0) AS bucket_id, SUM(consumed_point_units) AS units
FROM t_computing_consumption WHERE settlement_status = 'SUCCESS'
GROUP BY account_id, COALESCE(sub_account_id, 0)) cr
LEFT JOIN (SELECT account_id, COALESCE(sub_account_id, 0) AS bucket_id, SUM(-point_units) AS units
FROM t_computing_point_record WHERE type = 'CONSUME'
GROUP BY account_id, COALESCE(sub_account_id, 0)) pr ON pr.account_id = cr.account_id AND pr.bucket_id = cr.bucket_id
WHERE COALESCE(pr.units, 0) <> cr.units
UNION ALL
SELECT 'consumption_bucket_orphan', pr.account_id, pr.bucket_id, 0, pr.units
FROM (SELECT account_id, COALESCE(sub_account_id, 0) AS bucket_id, SUM(-point_units) AS units
FROM t_computing_point_record WHERE type = 'CONSUME'
GROUP BY account_id, COALESCE(sub_account_id, 0)) pr
LEFT JOIN (SELECT account_id, COALESCE(sub_account_id, 0) AS bucket_id, SUM(consumed_point_units) AS units
FROM t_computing_consumption WHERE settlement_status = 'SUCCESS'
GROUP BY account_id, COALESCE(sub_account_id, 0)) cr ON cr.account_id = pr.account_id AND cr.bucket_id = pr.bucket_id
WHERE cr.bucket_id IS NULL;
# SaaS 接入与迁移材料
这里的 SQL 只针对 **SaaS 数据库**,不在独立算力库执行,也不属于网关初始化步骤。
- [source_precheck.sql](source_precheck.sql):只读检查旧算力表、关联、未清记录和号段,实施迁移前使用。
- [binding_and_meiji_request_id.sql](binding_and_meiji_request_id.sql):保留原接入方案的交付内容;当前独立服务已经支持门店/员工 Key,实际对接时应重新核对凭证映射设计及 SaaS 目标结构,不视为已验收的最终迁移方案。
原接入脚本的 `computing_request_id` 增量与旧阶段 0 脚本存在重叠。执行前检查列及索引是否已存在,明确选择需要执行的语句;不要运行后忽略重复列错误或使用 `--force`。原阶段 0 文件现位于 `../archive/20260922_computing_stage0_ddl.sql`。
本次仅整理交付文件,未执行 SaaS 建表、改表或数据迁移。
-- P5 SaaS 侧交付(2026-09-23):手动执行,不由 Python 服务自动执行。 -- SaaS 接入方案材料(原 P5,2026-09-23),不是独立网关初始化脚本。
-- 当前门店/员工凭证映射需在正式对接时另行核对;参见本目录 README.md。
-- 1) 商户算力服务绑定表:SaaS 商户与 Python 独立算力账户(18 位数值 ID)一对一绑定, -- 1) 商户算力服务绑定表:SaaS 商户与 Python 独立算力账户(18 位数值 ID)一对一绑定,
-- 保存开户恢复请求号与 CALL Key AES-GCM 密文(base64,密钥来自部署 Secret,不入库明文)。 -- 保存开户恢复请求号与 CALL Key AES-GCM 密文(base64,密钥来自部署 Secret,不入库明文)。
-- 2) 美际分析表补充 computing_request_id:稳定业务请求号 meiji-skin-analysis:{analysisId}, -- 2) 美际分析表补充 computing_request_id:稳定业务请求号 meiji-skin-analysis:{analysisId},
-- 用于按原请求号回查 /api/v1/calls/{request_id},不换号重发。 -- 用于按原请求号回查 /api/v1/calls/{request_id},不换号重发。
-- 注意:阶段 0 脚本 20260922_computing_stage0_ddl.sql 已含同一列增量;若该脚本已在目标库执行, -- 注意:../archive/20260922_computing_stage0_ddl.sql 已含同一列增量。
-- 本段 ALTER 会因列重复失败,跳过即可(仅执行第 1 段建表)。 -- 执行前检查目标列和索引,选择尚未执行的语句;不要忽略重复列错误或使用 --force。
-- 遵循算力迁移 ID 规则:业务数值 ID 为 18 位、显式插入、无自增;本表主键沿用 SaaS 既有发号器生成的 BIGINT。 -- 遵循算力迁移 ID 规则:业务数值 ID 为 18 位、显式插入、无自增;本表主键沿用 SaaS 既有发号器生成的 BIGINT。
CREATE TABLE `t_saas_ai_computing_service_binding` CREATE TABLE `t_saas_ai_computing_service_binding`
......
-- Current independent account schema; generated by scripts/render_account_ddl.py.
-- Canonical output: scripts/sql/schema.sql (development baseline, not an upgrade).
-- Includes account hierarchy, scopes, monthly quotas and ledger indexes.
-- Target: a NEW, explicitly selected computing schema, MySQL >= 8.0.16.
-- Review and execute manually. Do not run on the existing SaaS schema.
-- No CREATE DATABASE/USE, no account grants, no source data changes, no ID seed.
-- No IF NOT EXISTS: existing tables must stop execution; never continue with --force.
-- After import, copy the committed source ID high-water mark before issuing IDs.
-- For a genuinely empty installation only, initialize computing-global at 100000000000000000.
-- Retrying this DDL after partial failure requires inspecting the target, not overwriting it.
-- Rollback: keep the old schema untouched; discard this target only before any new writes.
-- Once IDs or gateway side effects exist, reconcile before any rollback; never rewind IDs.
CREATE TABLE t_computing_client (
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
name VARCHAR(100) NOT NULL,
status ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (client_id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_id_segment (
generator_key VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
next_id BIGINT NOT NULL,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (generator_key),
CONSTRAINT ck_computing_id_segment_range CHECK (generator_key = 'computing-global' AND next_id >= 100000000000000000 AND next_id <= 1000000000000000000)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_account (
id BIGINT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
remark VARCHAR(500),
owner_client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
provision_status ENUM('PENDING','READY','FAILED','NEEDS_RECONCILIATION') NOT NULL DEFAULT 'PENDING',
provision_phase ENUM('REGISTERED','GATEWAY_WRITING','CONFIRMED','FAILED_CLEAR','NEEDS_RECONCILIATION') NOT NULL DEFAULT 'REGISTERED',
balance_point_units BIGINT NOT NULL DEFAULT 0,
gateway_token_id BIGINT UNSIGNED,
gateway_token_name VARCHAR(100),
gateway_snapshot JSON,
gift_gate BIGINT UNSIGNED,
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
FOREIGN KEY(owner_client_id) REFERENCES t_computing_client (client_id),
UNIQUE (gateway_token_id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_account_owner_created ON t_computing_account (owner_client_id, create_time, id);
CREATE TABLE t_computing_account_client (
account_id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status ENUM('AUTHORIZED','REVOKED') NOT NULL DEFAULT 'AUTHORIZED',
granted_by_platform_credential_id BIGINT UNSIGNED,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (account_id, client_id),
FOREIGN KEY(account_id) REFERENCES t_computing_account (id),
FOREIGN KEY(client_id) REFERENCES t_computing_client (client_id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_sub_account (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
remark VARCHAR(500),
status ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
balance_point_units BIGINT NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_sub_account_name UNIQUE (account_id, name),
FOREIGN KEY(account_id) REFERENCES t_computing_account (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_sub_account_created ON t_computing_sub_account (account_id, create_time, id);
CREATE TABLE t_computing_scope (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
sub_account_id BIGINT UNSIGNED,
scope_type ENUM('STORE','EMPLOYEE') NOT NULL,
scope_key VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
parent_scope_id BIGINT UNSIGNED,
status ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_scope_identity UNIQUE (scope_type, scope_key),
CONSTRAINT ck_computing_scope_shape CHECK ((scope_type = 'STORE' AND parent_scope_id IS NULL) OR (scope_type = 'EMPLOYEE' AND parent_scope_id IS NOT NULL AND sub_account_id IS NULL)),
CONSTRAINT fk_computing_scope_account FOREIGN KEY(account_id) REFERENCES t_computing_account (id),
CONSTRAINT fk_computing_scope_sub_account FOREIGN KEY(sub_account_id) REFERENCES t_computing_sub_account (id),
CONSTRAINT fk_computing_scope_parent FOREIGN KEY(parent_scope_id) REFERENCES t_computing_scope (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_scope_parent ON t_computing_scope (parent_scope_id);
CREATE INDEX idx_computing_scope_sub_account ON t_computing_scope (sub_account_id, scope_type, status);
CREATE TABLE t_computing_credential (
id BIGINT UNSIGNED NOT NULL,
category ENUM('CALL','INTEGRATION','PLATFORM') NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id BIGINT UNSIGNED,
sub_account_id BIGINT UNSIGNED,
scope_id BIGINT UNSIGNED,
secret_digest VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
secret_mask VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status ENUM('PENDING','ACTIVE','REVOKED') NOT NULL DEFAULT 'PENDING',
pending_expires_at DATETIME(3),
valid_until DATETIME(3),
activated_at DATETIME(3),
replaced_by BIGINT UNSIGNED,
issued_by BIGINT UNSIGNED,
revoke_reason VARCHAR(200),
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT fk_computing_credential_account_client FOREIGN KEY(account_id, client_id) REFERENCES t_computing_account_client (account_id, client_id),
CONSTRAINT ck_computing_credential_scope CHECK ((category = 'CALL' AND account_id IS NOT NULL AND issued_by IS NOT NULL AND NOT (sub_account_id IS NOT NULL AND scope_id IS NOT NULL)) OR (category IN ('INTEGRATION', 'PLATFORM') AND account_id IS NULL AND sub_account_id IS NULL AND scope_id IS NULL)),
FOREIGN KEY(client_id) REFERENCES t_computing_client (client_id),
FOREIGN KEY(sub_account_id) REFERENCES t_computing_sub_account (id),
FOREIGN KEY(scope_id) REFERENCES t_computing_scope (id),
UNIQUE (secret_digest),
FOREIGN KEY(replaced_by) REFERENCES t_computing_credential (id),
FOREIGN KEY(issued_by) REFERENCES t_computing_credential (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_credential_scope_status ON t_computing_credential (account_id, client_id, status);
CREATE TABLE t_computing_scope_month_usage (
scope_id BIGINT UNSIGNED NOT NULL,
month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
used_point_units BIGINT NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (scope_id, month),
CONSTRAINT fk_computing_scope_month_usage_scope FOREIGN KEY(scope_id) REFERENCES t_computing_scope (id),
CONSTRAINT ck_computing_scope_month_usage_nonnegative CHECK (used_point_units >= 0)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_scope_quota (
id BIGINT UNSIGNED NOT NULL,
scope_id BIGINT UNSIGNED NOT NULL,
store_scope_id BIGINT UNSIGNED NOT NULL,
monthly_quota_point_units BIGINT,
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_scope_quota_pair UNIQUE (scope_id, store_scope_id),
CONSTRAINT ck_computing_scope_quota_nonnegative CHECK (monthly_quota_point_units IS NULL OR monthly_quota_point_units >= 0),
CONSTRAINT fk_computing_scope_quota_scope FOREIGN KEY(scope_id) REFERENCES t_computing_scope (id),
CONSTRAINT fk_computing_scope_quota_store FOREIGN KEY(store_scope_id) REFERENCES t_computing_scope (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_call (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
fingerprint VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
business_code VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
business_ref VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
requested_model VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
model VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
dispatch_phase ENUM('REGISTERED','DISPATCHING','RESPONDED') NOT NULL DEFAULT 'REGISTERED',
dispatch_deadline DATETIME(3),
execution_status ENUM('PENDING','PROCESSING','SUCCEEDED','FAILED','UNKNOWN') NOT NULL DEFAULT 'PENDING',
billing_status ENUM('PROCESSING','SUCCESS','FAILED','SETTLE_PENDING','SETTLE_FAILED','UNKNOWN') NOT NULL DEFAULT 'PROCESSING',
settled_flag BOOL NOT NULL DEFAULT 0,
gateway_request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
gateway_error_snapshot JSON,
input_tokens BIGINT,
cache_hit_input_tokens BIGINT,
output_tokens BIGINT,
total_tokens BIGINT,
consumed_quota BIGINT,
point_units_per_quota BIGINT,
consumed_point_units BIGINT,
consumption_record_id BIGINT UNSIGNED,
result_json MEDIUMTEXT,
result_cleared_at DATETIME(3),
error_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin,
error_message VARCHAR(500),
lease_owner VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin,
lease_until DATETIME(3),
version INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_time DATETIME(3),
reconciled_time DATETIME(3),
reconciliation_note VARCHAR(500),
reconciled_by BIGINT UNSIGNED,
complete_time DATETIME(3),
sub_account_id BIGINT UNSIGNED,
scope_id BIGINT UNSIGNED,
store_scope_id BIGINT UNSIGNED,
quota_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT fk_computing_call_account_client FOREIGN KEY(account_id, client_id) REFERENCES t_computing_account_client (account_id, client_id),
CONSTRAINT uq_computing_call_request UNIQUE (request_id, account_id, client_id),
CONSTRAINT uq_computing_call_scope UNIQUE (id, account_id, client_id),
CONSTRAINT ck_computing_call_settled_flag CHECK ((billing_status = 'SUCCESS' AND settled_flag = 1) OR (billing_status <> 'SUCCESS' AND settled_flag = 0)),
CONSTRAINT ck_computing_call_settlement CHECK ((billing_status = 'SUCCESS' AND consumption_record_id IS NOT NULL AND consumed_quota IS NOT NULL AND point_units_per_quota IS NOT NULL AND consumed_point_units IS NOT NULL AND consumed_quota >= 0 AND point_units_per_quota > 0 AND consumed_point_units = consumed_quota * point_units_per_quota) OR (billing_status <> 'SUCCESS' AND consumption_record_id IS NULL AND consumed_quota IS NULL AND point_units_per_quota IS NULL AND consumed_point_units IS NULL)),
CONSTRAINT ck_computing_call_counters CHECK (retry_count >= 0 AND version >= 0),
CONSTRAINT ck_computing_call_lease CHECK ((lease_owner IS NULL AND lease_until IS NULL) OR (lease_owner IS NOT NULL AND lease_until IS NOT NULL)),
UNIQUE (gateway_request_id),
UNIQUE (consumption_record_id),
FOREIGN KEY(reconciled_by) REFERENCES t_computing_credential (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_call_account_created ON t_computing_call (account_id, create_time, id);
CREATE INDEX idx_computing_call_retry ON t_computing_call (billing_status, next_retry_time, lease_until);
CREATE INDEX idx_computing_call_scope_subject ON t_computing_call (account_id, scope_id, create_time, id);
CREATE INDEX idx_computing_call_store_scope ON t_computing_call (account_id, store_scope_id, create_time, id);
CREATE TABLE t_computing_gift (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
actor_credential_id BIGINT UNSIGNED,
request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
fingerprint VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
requested_point_units BIGINT NOT NULL,
quota_delta BIGINT NOT NULL,
credited_point_units BIGINT,
point_units_per_quota BIGINT NOT NULL DEFAULT 146,
gateway_quota_before BIGINT,
gateway_quota_after BIGINT,
gateway_target_quota BIGINT,
balance_before_units BIGINT,
balance_after_units BIGINT,
operator_note VARCHAR(200),
phase ENUM('REGISTERED','GATEWAY_WRITING','CONFIRMED','FAILED_CLEAR','NEEDS_RECONCILIATION') NOT NULL DEFAULT 'REGISTERED',
status ENUM('PROCESSING','SUCCESS','FAILED','NEEDS_RECONCILIATION') NOT NULL DEFAULT 'PROCESSING',
error_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin,
error_message VARCHAR(500),
reconciled_time DATETIME(3),
reconciliation_note VARCHAR(500),
reconciled_by BIGINT UNSIGNED,
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT fk_computing_gift_account_client FOREIGN KEY(account_id, client_id) REFERENCES t_computing_account_client (account_id, client_id),
CONSTRAINT uq_computing_gift_request UNIQUE (request_id, account_id, client_id),
CONSTRAINT uq_computing_gift_scope UNIQUE (id, account_id, client_id),
CONSTRAINT ck_computing_gift_positive_amounts CHECK (requested_point_units > 0 AND quota_delta > 0 AND point_units_per_quota > 0),
CONSTRAINT ck_computing_gift_credit CHECK ((status = 'SUCCESS' AND phase = 'CONFIRMED' AND credited_point_units IS NOT NULL AND balance_before_units IS NOT NULL AND balance_after_units IS NOT NULL AND credited_point_units = quota_delta * point_units_per_quota AND balance_after_units = balance_before_units + credited_point_units) OR (status <> 'SUCCESS' AND credited_point_units IS NULL AND balance_before_units IS NULL AND balance_after_units IS NULL)),
CONSTRAINT ck_computing_gift_phase_status CHECK ((status = 'PROCESSING' AND phase IN ('REGISTERED', 'GATEWAY_WRITING')) OR (status = 'SUCCESS' AND phase = 'CONFIRMED') OR (status = 'FAILED' AND phase = 'FAILED_CLEAR') OR (status = 'NEEDS_RECONCILIATION' AND phase = 'NEEDS_RECONCILIATION')),
FOREIGN KEY(actor_credential_id) REFERENCES t_computing_credential (id),
FOREIGN KEY(reconciled_by) REFERENCES t_computing_credential (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_gift_account_created ON t_computing_gift (account_id, create_time, id);
CREATE TABLE t_computing_management_request (
id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
actor_credential_id BIGINT UNSIGNED,
operation_type ENUM('PROVISION_ACCOUNT','ISSUE_CALL_KEY','ACTIVATE_CREDENTIAL','REVOKE_CREDENTIAL','GRANT_CLIENT','REVOKE_CLIENT','SET_ACCOUNT_STATUS','RECONCILE','CREATE_SUB_ACCOUNT','SET_SUB_ACCOUNT_STATUS','TRANSFER','BIND_SCOPE','SET_SCOPE_STATUS','MOVE_STORE','MOVE_EMPLOYEE','SET_SCOPE_QUOTA') NOT NULL,
request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
fingerprint VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
target_id BIGINT UNSIGNED,
status ENUM('PENDING','SUCCEEDED','FAILED','NEEDS_RECONCILIATION') NOT NULL DEFAULT 'PENDING',
result_ref JSON,
error_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin,
error_message VARCHAR(500),
version INTEGER NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_management_request_identity UNIQUE (request_id, client_id, operation_type),
FOREIGN KEY(client_id) REFERENCES t_computing_client (client_id),
FOREIGN KEY(actor_credential_id) REFERENCES t_computing_credential (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE TABLE t_computing_operation_audit (
id BIGINT UNSIGNED NOT NULL,
actor_credential_id BIGINT UNSIGNED,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id BIGINT UNSIGNED,
action VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
target_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
target_id BIGINT UNSIGNED,
request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
from_status VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin,
to_status VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin,
reason VARCHAR(500) NOT NULL,
evidence_ref JSON,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT uq_computing_operation_audit_request UNIQUE (request_id, client_id, action, target_type, target_id),
FOREIGN KEY(actor_credential_id) REFERENCES t_computing_credential (id),
FOREIGN KEY(client_id) REFERENCES t_computing_client (client_id),
FOREIGN KEY(account_id) REFERENCES t_computing_account (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_operation_audit_account_created ON t_computing_operation_audit (account_id, create_time, id);
CREATE TABLE t_computing_consumption (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
call_id BIGINT UNSIGNED,
gateway_request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
gateway_token_id BIGINT UNSIGNED,
business_code VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
business_ref VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
model VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
input_tokens BIGINT,
cache_hit_input_tokens BIGINT,
output_tokens BIGINT,
total_tokens BIGINT,
consumed_quota BIGINT,
point_units_per_quota BIGINT,
consumed_point_units BIGINT,
settlement_status ENUM('PROCESSING','SUCCESS','FAILED','SETTLE_PENDING','SETTLE_FAILED','UNKNOWN') NOT NULL,
settlement_source ENUM('GATEWAY_LOG','MANUAL','LEGACY'),
settled_time DATETIME(3),
legacy_record BOOL NOT NULL DEFAULT 0,
sub_account_id BIGINT UNSIGNED,
scope_id BIGINT UNSIGNED,
store_scope_id BIGINT UNSIGNED,
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT fk_computing_consumption_account_client FOREIGN KEY(account_id, client_id) REFERENCES t_computing_account_client (account_id, client_id),
CONSTRAINT fk_computing_consumption_call FOREIGN KEY(call_id, account_id, client_id) REFERENCES t_computing_call (id, account_id, client_id),
CONSTRAINT uq_computing_consumption_request UNIQUE (request_id, account_id, client_id),
CONSTRAINT uq_computing_consumption_scope UNIQUE (id, account_id, client_id),
CONSTRAINT ck_computing_consumption_settlement CHECK ((settlement_status = 'SUCCESS' AND consumed_quota IS NOT NULL AND point_units_per_quota IS NOT NULL AND consumed_point_units IS NOT NULL AND settlement_source IS NOT NULL AND settled_time IS NOT NULL AND consumed_quota >= 0 AND point_units_per_quota > 0 AND consumed_point_units = consumed_quota * point_units_per_quota) OR (settlement_status <> 'SUCCESS' AND consumed_quota IS NULL AND point_units_per_quota IS NULL AND consumed_point_units IS NULL)),
CONSTRAINT ck_computing_consumption_call CHECK (legacy_record = 1 OR call_id IS NOT NULL),
UNIQUE (call_id),
UNIQUE (gateway_request_id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_consumption_account_created ON t_computing_consumption (account_id, create_time, id);
CREATE INDEX idx_computing_consumption_scope_subject ON t_computing_consumption (account_id, scope_id, create_time, id);
CREATE INDEX idx_computing_consumption_store_scope ON t_computing_consumption (account_id, store_scope_id, create_time, id);
CREATE TABLE t_computing_point_record (
id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
type ENUM('GIFT','CONSUME','TRANSFER_OUT','TRANSFER_IN') NOT NULL,
point_units BIGINT NOT NULL,
balance_before_units BIGINT NOT NULL,
balance_after_units BIGINT NOT NULL,
point_units_per_quota BIGINT,
gift_id BIGINT UNSIGNED,
call_id BIGINT UNSIGNED,
consumption_record_id BIGINT UNSIGNED,
sub_account_id BIGINT UNSIGNED,
legacy_record BOOL NOT NULL DEFAULT 0,
request_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin,
remark VARCHAR(500),
create_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
last_update_time DATETIME(3) NOT NULL DEFAULT (UTC_TIMESTAMP(3)),
PRIMARY KEY (id),
CONSTRAINT fk_computing_point_record_account_client FOREIGN KEY(account_id, client_id) REFERENCES t_computing_account_client (account_id, client_id),
CONSTRAINT fk_computing_point_record_gift FOREIGN KEY(gift_id, account_id, client_id) REFERENCES t_computing_gift (id, account_id, client_id),
CONSTRAINT fk_computing_point_record_call FOREIGN KEY(call_id, account_id, client_id) REFERENCES t_computing_call (id, account_id, client_id),
CONSTRAINT fk_computing_point_record_consumption FOREIGN KEY(consumption_record_id, account_id, client_id) REFERENCES t_computing_consumption (id, account_id, client_id),
CONSTRAINT ck_computing_point_record_balance CHECK (balance_after_units = balance_before_units + point_units),
CONSTRAINT ck_computing_point_record_source CHECK ((type = 'GIFT' AND point_units > 0 AND call_id IS NULL AND consumption_record_id IS NULL AND sub_account_id IS NULL AND (legacy_record = 1 OR gift_id IS NOT NULL)) OR (type = 'CONSUME' AND point_units < 0 AND gift_id IS NULL AND consumption_record_id IS NOT NULL AND (legacy_record = 1 OR call_id IS NOT NULL)) OR (type = 'TRANSFER_OUT' AND point_units < 0 AND gift_id IS NULL AND call_id IS NULL AND consumption_record_id IS NULL) OR (type = 'TRANSFER_IN' AND point_units > 0 AND gift_id IS NULL AND call_id IS NULL AND consumption_record_id IS NULL)),
UNIQUE (gift_id),
UNIQUE (call_id),
UNIQUE (consumption_record_id),
FOREIGN KEY(sub_account_id) REFERENCES t_computing_sub_account (id)
)ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_computing_point_record_account_created ON t_computing_point_record (account_id, create_time, id);
CREATE INDEX idx_computing_point_record_bucket ON t_computing_point_record (account_id, sub_account_id, type);
...@@ -149,7 +149,9 @@ def calling(management): ...@@ -149,7 +149,9 @@ def calling(management):
m.account_id = open_account(m) m.account_id = open_account(m)
m.gateway = CallGateway(m) m.gateway = CallGateway(m)
m.service.gateway = m.gateway m.service.gateway = m.gateway
m.credential = issue(m, m.account_id) # An account-level CALL key is a platform-only facility (2026-09-30): the key
# still belongs to client-a, which is what these call tests exercise.
m.credential = issue(m, m.account_id, source="platform", client_id="client-a")
activate(m, m.credential) activate(m, m.credential)
m.secret = m.credential["secret"] m.secret = m.credential["secret"]
m.calls = CallService(m.service) m.calls = CallService(m.service)
...@@ -546,7 +548,7 @@ def test_rotation_and_changed_default_replay_original_snapshot_before_admission( ...@@ -546,7 +548,7 @@ def test_rotation_and_changed_default_replay_original_snapshot_before_admission(
assert original.status_code == 200, original.text assert original.status_code == 200, original.text
data = original.json()["data"] data = original.json()["data"]
row = stored_call(m) row = stored_call(m)
new = issue(m, m.account_id, request_id="rotate-p4-issue") new = issue(m, m.account_id, request_id="rotate-p4-issue", source="platform", client_id="client-a")
activate(m, new, request_id="rotate-p4-activate", replaces=m.credential["credentialId"]) activate(m, new, request_id="rotate-p4-activate", replaces=m.credential["credentialId"])
m.service.settings = replace(m.service.settings, newapi_model="replacement-model") m.service.settings = replace(m.service.settings, newapi_model="replacement-model")
with m.factory.begin() as session: with m.factory.begin() as session:
...@@ -608,7 +610,7 @@ def test_results_and_request_identity_are_account_and_client_scoped(funded, scop ...@@ -608,7 +610,7 @@ def test_results_and_request_identity_are_account_and_client_scoped(funded, scop
assert original.status_code == 200, original.text assert original.status_code == 200, original.text
if scope == "account": if scope == "account":
other_account = open_account(m, request_id="other-p4-account") other_account = open_account(m, request_id="other-p4-account")
other = issue(m, other_account, request_id="other-p4-key") other = issue(m, other_account, request_id="other-p4-key", source="platform", client_id="client-a")
activate(m, other, request_id="other-p4-activate") activate(m, other, request_id="other-p4-activate")
assert gift(m, request_id="other-p4-gift", accountId=other_account).status_code == 200 assert gift(m, request_id="other-p4-gift", accountId=other_account).status_code == 200
else: else:
......
...@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker ...@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
from app import account_repository as repository from app import account_repository as repository
from app.account_models import ( from app.account_models import (
Account, AccountClient, Base, Call, Client, Consumption, Credential, Gift, Account, AccountClient, Base, Call, Client, Consumption, Credential, Gift,
IdSegment, ManagementRequest, PointRecord, IdSegment, ManagementRequest, PointRecord, SubAccount,
) )
from app.db import create_mysql_engine from app.db import create_mysql_engine
from app.id_generator import GENERATOR_KEY, ID_LOW, SEGMENT_SIZE, SegmentIDGenerator, seed_segment from app.id_generator import GENERATOR_KEY, ID_LOW, SEGMENT_SIZE, SegmentIDGenerator, seed_segment
...@@ -48,13 +48,15 @@ def account_engine(request): ...@@ -48,13 +48,15 @@ def account_engine(request):
"mysql+pymysql://root@localhost/" + database + "?unix_socket=" + str(socket_path) "mysql+pymysql://root@localhost/" + database + "?unix_socket=" + str(socket_path)
) )
try: try:
script = Path(__file__).resolve().parents[1] / "scripts/20260923_independent_account_ddl.sql" # Fresh installations and MySQL tests use the same current full schema.
ddl = "\n".join(line for line in script.read_text(encoding="utf-8").splitlines() scripts = [Path(__file__).resolve().parents[1] / "scripts/sql/schema.sql"]
if not line.lstrip().startswith("--"))
with engine.begin() as connection: with engine.begin() as connection:
for statement in ddl.split(";"): for script in scripts:
if statement.strip(): ddl = "\n".join(line for line in script.read_text(encoding="utf-8").splitlines()
connection.execute(text(statement)) if not line.lstrip().startswith("--"))
for statement in ddl.split(";"):
if statement.strip():
connection.execute(text(statement))
yield engine yield engine
finally: finally:
engine.dispose() engine.dispose()
...@@ -104,7 +106,7 @@ def gift(id, account_id=A, client_id="mei1-saas", request_id="same-request"): ...@@ -104,7 +106,7 @@ def gift(id, account_id=A, client_id="mei1-saas", request_id="same-request"):
def test_schema_has_no_saas_dependencies(account_engine): def test_schema_has_no_saas_dependencies(account_engine):
names = inspect(account_engine).get_table_names() names = inspect(account_engine).get_table_names()
assert len(names) == 11 assert len(names) == 15
assert all(name.startswith("t_computing_") for name in names) assert all(name.startswith("t_computing_") for name in names)
for table in Base.metadata.tables.values(): for table in Base.metadata.tables.values():
assert not {"merchant_id", "store_id", "employee_id", "source_app"} & set(table.columns.keys()) assert not {"merchant_id", "store_id", "employee_id", "source_app"} & set(table.columns.keys())
...@@ -300,7 +302,7 @@ def test_mysql_isolation_utc_and_schema_constraints(account_engine): ...@@ -300,7 +302,7 @@ def test_mysql_isolation_utc_and_schema_constraints(account_engine):
def test_generated_ddl_matches_models(): def test_generated_ddl_matches_models():
from scripts.render_account_ddl import render_ddl from scripts.render_account_ddl import render_ddl
script = Path(__file__).resolve().parents[1] / "scripts/20260923_independent_account_ddl.sql" script = Path(__file__).resolve().parents[1] / "scripts/sql/schema.sql"
assert script.read_text(encoding="utf-8") == render_ddl() assert script.read_text(encoding="utf-8") == render_ddl()
assert "AUTO_INCREMENT" not in render_ddl() assert "AUTO_INCREMENT" not in render_ddl()
assert "DEFAULT (UTC_TIMESTAMP(3))" in render_ddl() assert "DEFAULT (UTC_TIMESTAMP(3))" in render_ddl()
...@@ -484,14 +486,14 @@ def test_mysql_fork_while_generator_locked(account_engine): ...@@ -484,14 +486,14 @@ def test_mysql_fork_while_generator_locked(account_engine):
("failed-call-success-consumption", {"consumption_call_mismatch"}), ("failed-call-success-consumption", {"consumption_call_mismatch"}),
("wrong-point-call", {"point_consumption_mismatch"}), ("wrong-point-call", {"point_consumption_mismatch"}),
("null-point-call", {"point_consumption_mismatch"}), ("null-point-call", {"point_consumption_mismatch"}),
("pending-consumption-point", {"point_consumption_mismatch"}), ("pending-consumption-point", {"point_consumption_mismatch", "consumption_bucket_orphan"}),
]) ])
def test_mysql_target_checks_detect_inconsistent_links(account_session, account_engine, case, expected): def test_mysql_target_checks_detect_inconsistent_links(account_session, account_engine, case, expected):
if account_engine.dialect.name != "mysql": if account_engine.dialect.name != "mysql":
pytest.skip("目标对账 SQL 使用 MySQL NULL-safe 比较") pytest.skip("目标对账 SQL 使用 MySQL NULL-safe 比较")
s = account_session s = account_session
c = call(101) c = call(101, quota_month="2026-09")
s.add_all([c, call(102, request_id="other-call", billing_status="FAILED")]) s.add_all([c, call(102, request_id="other-call", billing_status="FAILED", quota_month="2026-09")])
s.flush() s.flush()
legacy = case in {"valid-legacy", "success-call-legacy-consumption"} legacy = case in {"valid-legacy", "success-call-legacy-consumption"}
r = consumption(201, None if legacy else 101, legacy_record=legacy) r = consumption(201, None if legacy else 101, legacy_record=legacy)
...@@ -520,18 +522,66 @@ def test_mysql_target_checks_detect_inconsistent_links(account_session, account_ ...@@ -520,18 +522,66 @@ def test_mysql_target_checks_detect_inconsistent_links(account_session, account_
s.get(Account, A).balance_point_units = -146 s.get(Account, A).balance_point_units = -146
seed_segment(s, ID_LOW + 10000, segment_model=IdSegment) seed_segment(s, ID_LOW + 10000, segment_model=IdSegment)
s.commit() s.commit()
script = Path(__file__).resolve().parents[1] / "scripts/20260923_account_target_check.sql" assert target_check_anomalies(s) == expected
def target_check_anomalies(session):
"""Execute the delivered read-only SQL, not an ORM approximation of it."""
script = Path(__file__).resolve().parents[1] / "scripts/sql/check.sql"
sql = "\n".join(line for line in script.read_text(encoding="utf-8").splitlines() sql = "\n".join(line for line in script.read_text(encoding="utf-8").splitlines()
if not line.lstrip().startswith("--")) if not line.lstrip().startswith("--"))
actual = set() actual = set()
for statement in sql.split(";"): for statement in sql.split(";"):
if statement.strip(): if statement.strip():
result = s.execute(text(statement)) result = session.execute(text(statement))
if "anomaly" in result.keys(): if "anomaly" in result.keys():
actual.update(row.anomaly for row in result) actual.update(row.anomaly for row in result)
else: else:
result.all() result.all()
assert actual == expected return actual
def test_mysql_target_checks_keep_account_buckets_separate(account_session, account_engine):
if account_engine.dialect.name != "mysql":
pytest.skip("目标对账 SQL 使用 MySQL NULL-safe 比较")
s = account_session
# Equal and opposite errors must not cancel across the two NULL buckets.
for offset, account_id, consumed, recorded in [(0, A, 146, 292), (1, B, 292, 146)]:
row = consumption(201 + offset, None, account_id=account_id,
request_id="bucket-check", legacy_record=True)
row.consumed_quota, row.consumed_point_units = consumed // 146, consumed
s.add(row)
s.flush()
s.add(PointRecord(id=301 + offset, account_id=account_id, client_id="mei1-saas",
type="CONSUME", consumption_record_id=row.id, legacy_record=True,
point_units=-recorded, balance_before_units=0, balance_after_units=-recorded))
s.get(Account, account_id).balance_point_units = -recorded
seed_segment(s, ID_LOW + 10000, segment_model=IdSegment)
s.commit()
assert "consumption_bucket_mismatch" in target_check_anomalies(s)
def test_mysql_target_checks_separate_transfer_clients(account_session, account_engine):
if account_engine.dialect.name != "mysql":
pytest.skip("目标对账 SQL 使用 MySQL NULL-safe 比较")
s = account_session
s.add(SubAccount(id=401, account_id=A, name="transfer-bucket", balance_point_units=20000))
s.flush()
for offset, client_id in enumerate(["mei1-saas", "octop"]):
before = offset * 10000
s.add_all([
PointRecord(id=301 + offset * 2, account_id=A, client_id=client_id,
type="TRANSFER_OUT", request_id="shared-transfer-id", point_units=-10000,
balance_before_units=-before, balance_after_units=-before - 10000),
PointRecord(id=302 + offset * 2, account_id=A, client_id=client_id,
type="TRANSFER_IN", request_id="shared-transfer-id", point_units=10000,
balance_before_units=before, balance_after_units=before + 10000,
sub_account_id=401),
])
s.get(Account, A).balance_point_units = -20000
seed_segment(s, ID_LOW + 10000, segment_model=IdSegment)
s.commit()
assert target_check_anomalies(s) == set()
def test_database_ready_accepts_seeded_history(account_engine, account_session): def test_database_ready_accepts_seeded_history(account_engine, account_session):
......
...@@ -162,7 +162,7 @@ def test_gift_role_target_and_revocation_precedence(gifting): ...@@ -162,7 +162,7 @@ def test_gift_role_target_and_revocation_precedence(gifting):
m = gifting m = gifting
assert post(m, "/internal/v1/gifts", body(m)).status_code == 403 assert post(m, "/internal/v1/gifts", body(m)).status_code == 403
assert gift(m, clientId="client-b").status_code == 404 assert gift(m, clientId="client-b").status_code == 404
key = issue(m, m.account_id) key = issue(m, m.account_id, source="platform", client_id="client-a")
activate(m, key) activate(m, key)
assert post(m, "/internal/v1/gifts", body(m), key=key["secret"]).status_code == 403 assert post(m, "/internal/v1/gifts", body(m), key=key["secret"]).status_code == 403
data = gift(m).json()["data"] data = gift(m).json()["data"]
...@@ -466,6 +466,36 @@ def test_mysql_concurrent_reconcile_is_single_credit(gifting, monkeypatch): ...@@ -466,6 +466,36 @@ def test_mysql_concurrent_reconcile_is_single_credit(gifting, monkeypatch):
assert m.gateway.writes == [68493] assert m.gateway.writes == [68493]
def test_mysql_concurrent_reconcile_replay_never_returns_stale_view(gifting, monkeypatch):
"""同 requestId 的并发对账:败者必须回执最新视图,不能给过期 version/phase。
修复前 `_reconcile_state` 先读 gift 行、后查幂等行:另一个线程刚提交这笔对账时,
败者会拿到"operationStatus=SUCCEEDED + 提交前的 version/phase"(HTTP 202 202 的
过期回执),或被自己过期的预检版本判成版本冲突。这里连跑 3 轮做护栏。
"""
m = gifting
if m.engine.dialect.name != "mysql":
pytest.skip("requires MySQL row locks")
for round_index in range(3):
m.gateway.gift_failure = "lost-after"
data = gift(m, request_id="gift-request-%03d" % round_index).json()["data"]
# 每轮都要越过该笔赠送的派发窗口:时钟按轮次递增(DISPATCH_WINDOW_OPEN 会拦未过窗的)
later = utc_now() + timedelta(minutes=10 * (round_index + 1))
monkeypatch.setattr(gifts_module, "utc_now", lambda later=later: later)
m.gateway.check_transactions = False
request_id = "reconcile-request-%03d" % round_index
def confirm(_):
return reconcile(m, data, "CONFIRM_APPLIED", request_id=request_id)
with ThreadPoolExecutor(max_workers=3) as pool:
responses = list(pool.map(confirm, range(3)))
assert all(response.status_code == 200 for response in responses), [
(response.status_code, response.text[:200]) for response in responses]
assert len({response.json()["data"]["version"] for response in responses}) == 1
assert {response.json()["data"]["phase"] for response in responses} == {"CONFIRMED"}
def test_mysql_different_gifts_contend_on_durable_gate(gifting): def test_mysql_different_gifts_contend_on_durable_gate(gifting):
import threading import threading
m = gifting m = gifting
...@@ -499,7 +529,7 @@ def test_page_routes_are_readonly_and_validate_time_scope(gifting): ...@@ -499,7 +529,7 @@ def test_page_routes_are_readonly_and_validate_time_scope(gifting):
for values in [{"start": "2026-09-23T00:00:00"}, {"start": "2026-09-24T00:00:00Z", "end": "2026-09-23T00:00:00Z"}, for values in [{"start": "2026-09-23T00:00:00"}, {"start": "2026-09-24T00:00:00Z", "end": "2026-09-23T00:00:00Z"},
{"page": 0}, {"size": 201}, {"accountIds": [m.account_id] * 1001}]: {"page": 0}, {"size": 201}, {"accountIds": [m.account_id] * 1001}]:
assert post(m, "/internal/v1/gifts/page", values).status_code == 400 assert post(m, "/internal/v1/gifts/page", values).status_code == 400
key = issue(m, m.account_id) key = issue(m, m.account_id, source="platform", client_id="client-a")
activate(m, key) activate(m, key)
assert post(m, "/api/v1/consumptions/page", {}, key=key["secret"]).status_code == 200 assert post(m, "/api/v1/consumptions/page", {}, key=key["secret"]).status_code == 200
assert post(m, "/api/v1/consumptions/page", {"accountIds": [m.account_id]}, key=key["secret"]).status_code == 403 assert post(m, "/api/v1/consumptions/page", {"accountIds": [m.account_id]}, key=key["secret"]).status_code == 403
...@@ -316,7 +316,7 @@ def test_platform_requires_explicit_scope_validates_all_ids_and_audits_reads(led ...@@ -316,7 +316,7 @@ def test_platform_requires_explicit_scope_validates_all_ids_and_audits_reads(led
def test_call_scope_is_fixed_and_gifts_forbidden(ledger): def test_call_scope_is_fixed_and_gifts_forbidden(ledger):
m = ledger.m m = ledger.m
credential = issue(m, str(ledger.a)) credential = issue(m, str(ledger.a), source="platform", client_id="client-a")
activate(m, credential) activate(m, credential)
secret = credential["secret"] secret = credential["secret"]
result = page_consumptions(m.service, secret) result = page_consumptions(m.service, secret)
...@@ -392,7 +392,7 @@ def test_queries_select_only_financial_columns_and_page_in_sql(ledger, function, ...@@ -392,7 +392,7 @@ def test_queries_select_only_financial_columns_and_page_in_sql(ledger, function,
secret = m.keys["platform"] if role == "PLATFORM" else m.keys["client-a"] secret = m.keys["platform"] if role == "PLATFORM" else m.keys["client-a"]
parameters = {"account_ids": [str(ledger.a)]} if role == "PLATFORM" else {} parameters = {"account_ids": [str(ledger.a)]} if role == "PLATFORM" else {}
if role == "CALL": if role == "CALL":
credential = issue(m, str(ledger.a)) credential = issue(m, str(ledger.a), source="platform", client_id="client-a")
activate(m, credential) activate(m, credential)
secret = credential["secret"] secret = credential["secret"]
statements = [] statements = []
......
"""分账(段 3a):消费投影带出计费主体,且可按门店/员工/子账号维度分页。
Rows are inserted directly (same style as tests/test_account_ledger.py): the
projection and the filters are what is under test, not the gateway plumbing.
"""
import pytest
from app.account_ledger import page_consumptions, page_gifts
from app.account_security import AccountError
from tests.test_account_foundation import account_engine # noqa: F401 (fixture plumbing)
from tests.test_account_ledger import ID, NOW, _call, _consumption
from tests.test_account_management import management, open_account
from tests.test_account_scope import SCOPE_PATH, bind_employee, bind_store, issue_scope_key
from tests.test_account_sub_account import create_sub_account
def _world(m):
"""One account, one sub-account bucket, two stores (keyed / direct), one
employee, and one consumption row per charging subject."""
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "ledger-budget",
request_id="ledger-sub").json()["data"]["subAccountId"])
store_a = int(bind_store(m, account_id, "mei1:ledger-store-a", sub_id,
request_id="ledger-store-a").json()["data"]["scopeId"])
store_b = int(bind_store(m, account_id, "mei1:ledger-store-b",
request_id="ledger-store-b").json()["data"]["scopeId"])
employee = int(bind_employee(m, account_id, "mei1:ledger-emp", "mei1:ledger-store-a",
request_id="ledger-emp").json()["data"]["scopeId"])
with m.factory.begin() as session:
session.add_all([
_call(201, account_id, request_id="ledger-call-201", quota_month="2026-09"),
_call(202, account_id, request_id="ledger-call-202", quota_month="2026-09",
sub_account_id=sub_id, scope_id=store_a, store_scope_id=store_a),
_call(203, account_id, request_id="ledger-call-203", quota_month="2026-09",
sub_account_id=sub_id, scope_id=employee, store_scope_id=store_a),
_call(204, account_id, request_id="ledger-call-204", quota_month="2026-09",
scope_id=store_b, store_scope_id=store_b),
_consumption(301, account_id, scope_id=store_b, store_scope_id=store_b),
_consumption(302, account_id),
])
return account_id, sub_id, store_a, store_b, employee
def _page(m, key, **kwargs):
return page_consumptions(m.service, key, **kwargs)
def _ids(result):
return {row["id"] for row in result["list"]}
def test_consumption_projection_exposes_the_charging_subject(management):
m = management
account_id, sub_id, store_a, store_b, employee = _world(m)
result = _page(m, m.keys["client-a"], account_ids=[account_id], size=200)
rows = {row["id"]: row for row in result["list"]}
assert set(rows) == {str(ID + offset) for offset in [201, 202, 203, 204, 301, 302]}
# Account-level money answers to the account bucket and no subject at all.
assert (rows[str(ID + 201)]["subAccountId"], rows[str(ID + 201)]["scopeId"],
rows[str(ID + 201)]["storeScopeId"]) == (None, None, None)
assert rows[str(ID + 202)]["subAccountId"] == str(sub_id)
assert rows[str(ID + 202)]["scopeId"] == str(store_a)
assert rows[str(ID + 202)]["storeScopeId"] == str(store_a)
# An employee row carries its own scope and the store it belonged to.
assert rows[str(ID + 203)]["scopeId"] == str(employee)
assert rows[str(ID + 203)]["storeScopeId"] == str(store_a)
# A store keyed straight to the account charges no bucket but keeps its scope.
assert rows[str(ID + 204)]["subAccountId"] is None
assert rows[str(ID + 204)]["scopeId"] == rows[str(ID + 204)]["storeScopeId"] == str(store_b)
# Imported rows go through the same projection.
assert rows[str(ID + 301)]["storeScopeId"] == str(store_b)
assert rows[str(ID + 302)]["scopeId"] is None
def test_store_ledger_merges_the_store_and_its_employees(management):
m = management
account_id, sub_id, store_a, store_b, employee = _world(m)
result = _page(m, m.keys["client-a"], account_ids=[account_id], store_scope_ids=[store_a])
assert result["total"] == 2
assert _ids(result) == {str(ID + 202), str(ID + 203)}
other = _page(m, m.keys["client-a"], account_ids=[account_id], store_scope_ids=[store_b])
assert _ids(other) == {str(ID + 204), str(ID + 301)}
both = _page(m, m.keys["client-a"], account_ids=[account_id], store_scope_ids=[store_a, store_b])
assert both["total"] == 4
def test_employee_and_sub_account_ledgers_use_their_own_dimension(management):
m = management
account_id, sub_id, store_a, store_b, employee = _world(m)
employee_page = _page(m, m.keys["client-a"], account_ids=[account_id], scope_ids=[employee])
assert _ids(employee_page) == {str(ID + 203)}
# The bucket dimension is not the store dimension: store B is keyed straight
# to the account, so it never shows up in the sub-account's ledger.
bucket_page = _page(m, m.keys["client-a"], account_ids=[account_id], sub_account_ids=[sub_id])
assert _ids(bucket_page) == {str(ID + 202), str(ID + 203)}
combos = _page(m, m.keys["client-a"], account_ids=[account_id], scope_ids=[employee],
sub_account_ids=[sub_id], store_scope_ids=[store_a])
assert _ids(combos) == {str(ID + 203)}
def test_scope_filters_page_consistently_and_never_leak_across_accounts(management):
m = management
account_id, sub_id, store_a, store_b, employee = _world(m)
collected = []
for page in range(1, 3):
result = _page(m, m.keys["client-a"], account_ids=[account_id], store_scope_ids=[store_a],
page=page, size=1)
assert result["total"] == 2
collected.extend(row["id"] for row in result["list"])
assert collected == [str(ID + 203), str(ID + 202)]
# A scope of another account simply cannot widen the account scope.
other_account = open_account(m, request_id="ledger-other-account")
other_store = int(bind_store(m, other_account, "mei1:ledger-store-x",
request_id="ledger-store-x").json()["data"]["scopeId"])
empty = _page(m, m.keys["client-a"], account_ids=[account_id],
store_scope_ids=[store_a, other_store])
assert empty["total"] == 2
unrelated = _page(m, m.keys["client-a"], account_ids=[account_id], store_scope_ids=[other_store])
assert (unrelated["total"], unrelated["list"]) == (0, [])
def test_ledger_scope_filters_are_refused_to_call_keys(management):
m = management
account_id, sub_id, store_a, store_b, employee = _world(m)
secret = issue_scope_key(m, account_id, store_a, request_id="ledger-store-key")["secret"]
for kwargs in [{"store_scope_ids": [store_a]}, {"scope_ids": [employee]},
{"sub_account_ids": [sub_id]}]:
with pytest.raises(AccountError) as error:
page_consumptions(m.service, secret, **kwargs)
assert error.value.code == "PERMISSION_DENIED"
# The key can still read its own account's ledger with no identity override.
allowed = page_consumptions(m.service, secret)
assert allowed["total"] == 6
def test_gift_page_rejects_subject_filters(management):
m = management
account_id, sub_id, store_a, _, _ = _world(m)
with pytest.raises(AccountError) as error:
page_gifts(m.service, m.keys["client-a"], account_ids=[account_id], store_scope_ids=[store_a])
assert error.value.code == "INVALID_ARGUMENT"
...@@ -134,11 +134,11 @@ def test_independent_happy_path_and_one_time_delivery(management): ...@@ -134,11 +134,11 @@ def test_independent_happy_path_and_one_time_delivery(management):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
assert open_account(m) == account_id and m.gateway.creates == 1 assert open_account(m) == account_id and m.gateway.creates == 1
created = issue(m, account_id) created = issue(m, account_id, source="platform", client_id="client-a")
assert created["status"] == "PENDING" and created["secretAvailable"] assert created["status"] == "PENDING" and created["secretAvailable"]
denied = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"])) denied = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"]))
assert denied.status_code == 401 and denied.json()["code"] == "CREDENTIAL_PENDING" assert denied.status_code == 401 and denied.json()["code"] == "CREDENTIAL_PENDING"
replay = issue(m, account_id) replay = issue(m, account_id, source="platform", client_id="client-a")
assert replay["credentialId"] == created["credentialId"] and not replay["secretAvailable"] and "secret" not in replay assert replay["credentialId"] == created["credentialId"] and not replay["secretAvailable"] and "secret" not in replay
activate(m, created) activate(m, created)
response = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"])) response = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"]))
...@@ -169,7 +169,7 @@ def test_roles_scope_and_forged_identity(management): ...@@ -169,7 +169,7 @@ def test_roles_scope_and_forged_identity(management):
response = m.client.post("/internal/v1/accounts/%s/status" % account_id, response = m.client.post("/internal/v1/accounts/%s/status" % account_id,
json={"status": "DISABLED", "reason": "test"}, headers=forged) json={"status": "DISABLED", "reason": "test"}, headers=forged)
assert response.status_code == 403 assert response.status_code == 403
created = issue(m, account_id) created = issue(m, account_id, source="platform", client_id="client-a")
activate(m, created) activate(m, created)
assert post(m, "/internal/v1/accounts", {"name": "Forbidden"}, key=created["secret"]).status_code == 403 assert post(m, "/internal/v1/accounts", {"name": "Forbidden"}, key=created["secret"]).status_code == 403
assert post(m, "/internal/v1/credentials/%s/activate" % created["credentialId"], {}, source="client-b").status_code == 404 assert post(m, "/internal/v1/credentials/%s/activate" % created["credentialId"], {}, source="client-b").status_code == 404
...@@ -179,15 +179,20 @@ def test_roles_scope_and_forged_identity(management): ...@@ -179,15 +179,20 @@ def test_roles_scope_and_forged_identity(management):
def test_revocation_precedes_replay_and_new_issuance(management): def test_revocation_precedes_replay_and_new_issuance(management):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
created = issue(m, account_id) sub_id = post(m, "/internal/v1/accounts/%s/sub-accounts" % account_id, {"name": "budget"},
source="platform", request_id="create-sub-revoke").json()["data"]["subAccountId"]
created = post(m, "/internal/v1/accounts/%s/credentials" % account_id,
{"subAccountId": str(sub_id)}, request_id="issue-key-001").json()["data"]
activate(m, created) activate(m, created)
revoked = post(m, "/internal/v1/accounts/%s/clients" % account_id, revoked = post(m, "/internal/v1/accounts/%s/clients" % account_id,
{"clientId": "client-a", "status": "REVOKED", "reason": "revoke access"}, source="platform") {"clientId": "client-a", "status": "REVOKED", "reason": "revoke access"}, source="platform")
assert revoked.status_code == 200 assert revoked.status_code == 200
assert m.client.get("/api/v1/account", headers=headers(m, key=created["secret"])).status_code == 403 assert m.client.get("/api/v1/account", headers=headers(m, key=created["secret"])).status_code == 403
assert post(m, "/internal/v1/accounts/%s/credentials" % account_id, {}, request_id="issue-key-001").status_code == 403 assert post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"subAccountId": str(sub_id)},
request_id="issue-key-001").status_code == 403
assert m.client.get("/internal/v1/requests/ISSUE_CALL_KEY/issue-key-001", headers=headers(m)).status_code == 403 assert m.client.get("/internal/v1/requests/ISSUE_CALL_KEY/issue-key-001", headers=headers(m)).status_code == 403
assert post(m, "/internal/v1/accounts/%s/credentials" % account_id, {}, request_id="issue-key-new").status_code == 403 assert post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"subAccountId": str(sub_id)},
request_id="issue-key-new").status_code == 403
def test_platform_explicit_target_and_grant(management): def test_platform_explicit_target_and_grant(management):
...@@ -207,9 +212,9 @@ def test_platform_explicit_target_and_grant(management): ...@@ -207,9 +212,9 @@ def test_platform_explicit_target_and_grant(management):
def test_rotation_grace_does_not_extend_and_revoke_is_immediate(management): def test_rotation_grace_does_not_extend_and_revoke_is_immediate(management):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
old = issue(m, account_id) old = issue(m, account_id, source="platform", client_id="client-a")
activate(m, old) activate(m, old)
new = issue(m, account_id, request_id="issue-key-002") new = issue(m, account_id, request_id="issue-key-002", source="platform", client_id="client-a")
activate(m, new, request_id="rotate-key-001", replaces=old["credentialId"]) activate(m, new, request_id="rotate-key-001", replaces=old["credentialId"])
with m.factory() as s: with m.factory() as s:
deadline = s.get(Credential, int(old["credentialId"])).valid_until deadline = s.get(Credential, int(old["credentialId"])).valid_until
...@@ -228,12 +233,12 @@ def test_rotation_grace_does_not_extend_and_revoke_is_immediate(management): ...@@ -228,12 +233,12 @@ def test_rotation_grace_does_not_extend_and_revoke_is_immediate(management):
def test_expiry_boundaries_and_disabled_client(management): def test_expiry_boundaries_and_disabled_client(management):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
created = issue(m, account_id) created = issue(m, account_id, source="platform", client_id="client-a")
with m.factory.begin() as s: with m.factory.begin() as s:
s.get(Credential, int(created["credentialId"])).pending_expires_at = utc_now() - timedelta(seconds=1) s.get(Credential, int(created["credentialId"])).pending_expires_at = utc_now() - timedelta(seconds=1)
response = post(m, "/internal/v1/credentials/%s/activate" % created["credentialId"], {}) response = post(m, "/internal/v1/credentials/%s/activate" % created["credentialId"], {})
assert response.json()["code"] == "CREDENTIAL_EXPIRED" assert response.json()["code"] == "CREDENTIAL_EXPIRED"
newer = issue(m, account_id, request_id="newer-key-001") newer = issue(m, account_id, request_id="newer-key-001", source="platform", client_id="client-a")
activate(m, newer) activate(m, newer)
with m.factory.begin() as s: with m.factory.begin() as s:
s.get(Credential, int(newer["credentialId"])).valid_until = utc_now() - timedelta(seconds=1) s.get(Credential, int(newer["credentialId"])).valid_until = utc_now() - timedelta(seconds=1)
...@@ -336,9 +341,10 @@ def segment_outage(monkeypatch, generator): ...@@ -336,9 +341,10 @@ def segment_outage(monkeypatch, generator):
def test_credential_replay_survives_segment_failure(management, monkeypatch): def test_credential_replay_survives_segment_failure(management, monkeypatch):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
created = issue(m, account_id, request_id="segment-001") created = issue(m, account_id, request_id="segment-001", source="platform", client_id="client-a")
segment_outage(monkeypatch, m.service.generator) segment_outage(monkeypatch, m.service.generator)
replay = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {}, request_id="segment-001") replay = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"clientId": "client-a"},
request_id="segment-001", source="platform")
assert replay.status_code == 200, replay.text assert replay.status_code == 200, replay.text
assert replay.json()["data"]["credentialId"] == created["credentialId"] assert replay.json()["data"]["credentialId"] == created["credentialId"]
assert "secret" not in replay.json()["data"] assert "secret" not in replay.json()["data"]
...@@ -369,15 +375,19 @@ def test_cancelled_provision_preserves_dispatch_evidence(management): ...@@ -369,15 +375,19 @@ def test_cancelled_provision_preserves_dispatch_evidence(management):
def test_disabled_account_allows_reads_and_does_not_reset_provision(management): def test_disabled_account_allows_reads_and_does_not_reset_provision(management):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
created = issue(m, account_id) sub_id = post(m, "/internal/v1/accounts/%s/sub-accounts" % account_id, {"name": "budget"},
activate(m, created) source="platform", request_id="create-sub-disabled").json()["data"]["subAccountId"]
created = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"subAccountId": str(sub_id)},
request_id="issue-sub-disabled").json()["data"]
activate(m, created, request_id="activate-sub-disabled")
response = post(m, "/internal/v1/accounts/%s/status" % account_id, response = post(m, "/internal/v1/accounts/%s/status" % account_id,
{"status": "DISABLED", "reason": "maintenance"}, source="platform") {"status": "DISABLED", "reason": "maintenance"}, source="platform")
assert response.status_code == 200 assert response.status_code == 200
response = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"])) response = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"]))
assert response.status_code == 200 and "ACCOUNT_DISABLED" in response.json()["data"]["blockedReasons"] assert response.status_code == 200 and "ACCOUNT_DISABLED" in response.json()["data"]["blockedReasons"]
assert open_account(m) == account_id assert open_account(m) == account_id
assert post(m, "/internal/v1/accounts/%s/credentials" % account_id, {}, request_id="disabled-issue-01").status_code == 403 assert post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"subAccountId": str(sub_id)},
request_id="disabled-issue-01").status_code == 403
def test_bootstrap_is_idempotent_and_callback_failure_rolls_back(management): def test_bootstrap_is_idempotent_and_callback_failure_rolls_back(management):
...@@ -479,9 +489,9 @@ def test_same_client_management_roles_cannot_replay_privileged_results(managemen ...@@ -479,9 +489,9 @@ def test_same_client_management_roles_cannot_replay_privileged_results(managemen
def test_disabled_account_rejects_new_activation_but_allows_original_replay(management): def test_disabled_account_rejects_new_activation_but_allows_original_replay(management):
m = management m = management
account_id = open_account(m) account_id = open_account(m)
old = issue(m, account_id) old = issue(m, account_id, source="platform", client_id="client-a")
activate(m, old) activate(m, old)
new = issue(m, account_id, request_id="issue-while-active") new = issue(m, account_id, request_id="issue-while-active", source="platform", client_id="client-a")
assert post(m, "/internal/v1/accounts/%s/status" % account_id, assert post(m, "/internal/v1/accounts/%s/status" % account_id,
{"status": "DISABLED", "reason": "maintenance"}, source="platform").status_code == 200 {"status": "DISABLED", "reason": "maintenance"}, source="platform").status_code == 200
response = post(m, "/internal/v1/credentials/%s/activate" % new["credentialId"], response = post(m, "/internal/v1/credentials/%s/activate" % new["credentialId"],
...@@ -611,7 +621,7 @@ def test_mysql_concurrent_provision_and_issuance_are_once(management): ...@@ -611,7 +621,7 @@ def test_mysql_concurrent_provision_and_issuance_are_once(management):
account_id = int(outcomes[0]["accountId"]) account_id = int(outcomes[0]["accountId"])
def issue_once(_): def issue_once(_):
return m.service.issue_credential(m.keys["client-a"], "concurrent-issue-01", account_id) return m.service.issue_credential(m.keys["platform"], "concurrent-issue-01", account_id, "client-a")
with ThreadPoolExecutor(max_workers=4) as executor: with ThreadPoolExecutor(max_workers=4) as executor:
outcomes = list(executor.map(issue_once, range(4))) outcomes = list(executor.map(issue_once, range(4)))
......
"""超额/窗口可观测(段 3c):S31 结算越限、S30 结算重试、S16 调低越限。
三条都必须给出**结构化** warning(key=value 字段),运维才能据此设阈值;且只在
「跨越那一刻」记一条,不在超额期间逐笔刷屏。
"""
import asyncio
import logging
from datetime import timedelta
from app.account_ledger import current_month
from app.account_models import Call, ScopeMonthUsage
from tests.test_account_foundation import account_engine # noqa: F401 (fixture plumbing)
from tests.test_account_management import management, open_account
from tests.test_account_scope import bind_employee, bind_store, month_usage, set_quota
from tests.test_account_settlement import evidence_for, new_call, process, settlements # noqa: F401
def _world(m, tag):
account_id = m.account_id
store_id = int(bind_store(m, account_id, "mei1:alert-store-" + tag,
request_id="alert-store-" + tag).json()["data"]["scopeId"])
employee_id = int(bind_employee(m, account_id, "mei1:alert-emp-" + tag, "mei1:alert-store-" + tag,
request_id="alert-emp-" + tag).json()["data"]["scopeId"])
return store_id, employee_id
def _messages(caplog, marker):
return [record.getMessage() for record in caplog.records if marker in record.getMessage()]
def test_settlement_warns_once_when_the_month_usage_crosses_the_limit(settlements, caplog):
m = settlements
store_id, employee_id = _world(m, "cross")
assert set_quota(m, m.account_id, employee_id, store_id, 1,
request_id="alert-quota-cross").status_code == 200
with m.factory.begin() as session:
session.add(ScopeMonthUsage(scope_id=employee_id, month="2026-09", used_point_units=9900))
call_id = new_call(m, scope_id=employee_id, store_scope_id=store_id, quota_month="2026-09")
evidence_for(m, call_id, quota=1)
with caplog.at_level(logging.WARNING, logger="app.account_settlement"):
assert process(m, call_id)
assert month_usage(m, employee_id, "2026-09") == 10046
over = _messages(caplog, "scope month quota exceeded")
assert len(over) == 1
for token in ["accountId=%s" % m.account_id, "scopeId=%s" % employee_id,
"storeScopeId=%s" % store_id, "quotaMonth=2026-09", "limitPointUnits=10000",
"usedPointUnits=10046", "overPointUnits=46", "callId=%s" % call_id]:
assert token in over[0], (token, over[0])
def test_settlement_inside_the_limit_and_repeat_overage_stay_quiet(settlements, caplog):
m = settlements
store_id, employee_id = _world(m, "quiet")
assert set_quota(m, m.account_id, employee_id, store_id, 1,
request_id="alert-quota-quiet").status_code == 200
inside = new_call(m, scope_id=employee_id, store_scope_id=store_id, quota_month="2026-09")
evidence_for(m, inside, quota=1)
with caplog.at_level(logging.WARNING, logger="app.account_settlement"):
assert process(m, inside)
assert _messages(caplog, "scope month quota exceeded") == []
# Already over the limit before this settlement: the crossing was reported
# once already, so the stream stays quiet while the state persists.
m.clock.now += timedelta(minutes=5)
over = new_call(m, scope_id=employee_id, store_scope_id=store_id, quota_month="2026-09")
with m.factory.begin() as session:
session.get(Call, over).create_time = m.clock.now
session.get(ScopeMonthUsage, (employee_id, "2026-09")).used_point_units = 50000
evidence_for(m, over, quota=1)
caplog.clear()
with caplog.at_level(logging.WARNING, logger="app.account_settlement"):
assert process(m, over)
assert month_usage(m, employee_id, "2026-09") == 50146
assert _messages(caplog, "scope month quota exceeded") == []
def test_settlement_retry_and_giving_up_are_logged(settlements, caplog):
m = settlements
call_id = new_call(m)
with caplog.at_level(logging.WARNING, logger="app.account_settlement"):
assert asyncio.run(m.settlements.process(m.settlements.claim(call_id))) is False
retry = _messages(caplog, "settlement retry scheduled")
assert len(retry) == 1
for token in ["accountId=%s" % m.account_id, "callId=%s" % call_id, "code=SETTLEMENT_LOG_PENDING",
"retryCount=1", "nextRetryMinutes=1"]:
assert token in retry[0], (token, retry[0])
# 24 次之后放弃:同样要有一条能设告警的终态日志。
m.clock.now += timedelta(minutes=1)
with m.factory.begin() as session:
session.get(Call, call_id).retry_count = 24
caplog.clear()
with caplog.at_level(logging.WARNING, logger="app.account_settlement"):
assert asyncio.run(m.settlements.process(m.settlements.claim(call_id))) is False
stopped = _messages(caplog, "settlement stopped")
assert len(stopped) == 1
for token in ["callId=%s" % call_id, "code=SETTLEMENT_LOG_PENDING", "retryCount=25",
"status=SETTLE_FAILED"]:
assert token in stopped[0], (token, stopped[0])
def test_lowering_the_quota_below_this_month_usage_is_logged(management, caplog):
m = management
m.account_id = int(open_account(m))
store_id, employee_id = _world(m, "lower")
assert set_quota(m, m.account_id, employee_id, store_id, 5,
request_id="alert-quota-lower-1").status_code == 200
with m.factory.begin() as session:
session.add(ScopeMonthUsage(scope_id=employee_id, month=current_month(), used_point_units=60000))
with caplog.at_level(logging.WARNING, logger="app.account_service"):
assert set_quota(m, m.account_id, employee_id, store_id, 1,
request_id="alert-quota-lower-2").status_code == 200
notes = _messages(caplog, "scope quota below current month usage")
assert len(notes) == 1
for token in ["scopeId=%s" % employee_id, "storeScopeId=%s" % store_id,
"quotaMonth=%s" % current_month(), "limitPointUnits=10000", "usedPointUnits=60000"]:
assert token in notes[0], (token, notes[0])
# Raising it again is not a risk: no log.
caplog.clear()
with caplog.at_level(logging.WARNING, logger="app.account_service"):
assert set_quota(m, m.account_id, employee_id, store_id, 50,
request_id="alert-quota-lower-3").status_code == 200
assert _messages(caplog, "scope quota below current month usage") == []
import json
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import func, select
from app import account_repository as repository
from app.account_models import (
Account, Call, Consumption, Credential, OperationAudit, PointRecord, Scope, ScopeMonthUsage,
ScopeQuota, SubAccount,
)
from app.account_openai import openai_error
from app.account_security import AccountError
from tests.test_account_calls import calling, chat, chat_body, gift, stored_call # noqa: F401 (fixtures/helpers)
from tests.test_account_foundation import account_engine # noqa: F401 (fixture plumbing)
from tests.test_account_management import activate, headers, management, open_account, post
from tests.test_account_sub_account import create_sub_account, issue_sub_key, transfer
SCOPE_PATH = "/internal/v1/accounts/%s/scopes"
def current_month():
return datetime.now(timezone.utc).astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m")
def bind_store(m, account_id, scope_key, sub_account_id=None, *, request_id, source=None):
body = {"scopeType": "STORE", "scopeKey": scope_key}
if sub_account_id is not None:
body["subAccountId"] = str(sub_account_id)
return post(m, SCOPE_PATH % account_id, body, request_id=request_id,
**({"source": source} if source else {}))
def bind_employee(m, account_id, scope_key, parent_scope_key, *, request_id, source=None):
return post(m, SCOPE_PATH % account_id,
{"scopeType": "EMPLOYEE", "scopeKey": scope_key, "parentScopeKey": parent_scope_key},
request_id=request_id, **({"source": source} if source else {}))
def scope_status(m, account_id, scope_id, status, *, request_id, source=None):
return post(m, SCOPE_PATH % account_id + "/%s/status" % scope_id,
{"status": status, "reason": "test"}, request_id=request_id,
**({"source": source} if source else {}))
def move_store(m, account_id, scope_id, sub_account_id, *, request_id):
body = {"subAccountId": None if sub_account_id is None else str(sub_account_id)}
return post(m, SCOPE_PATH % account_id + "/%s/sub-account" % scope_id, body, request_id=request_id)
def move_employee(m, account_id, scope_id, parent_scope_id, *, request_id):
return post(m, SCOPE_PATH % account_id + "/%s/assignment" % scope_id,
{"parentScopeId": str(parent_scope_id)}, request_id=request_id)
def set_quota(m, account_id, scope_id, store_scope_id, points, *, request_id):
return post(m, SCOPE_PATH % account_id + "/%s/quota" % scope_id,
{"storeScopeId": str(store_scope_id), "monthlyQuotaPoints": points},
request_id=request_id)
def issue_scope_key(m, account_id, scope_id, *, request_id, source=None):
response = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"scopeId": str(scope_id)},
request_id=request_id, **({"source": source} if source else {}))
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["status"] == "PENDING" and data["secretAvailable"]
activate(m, data, request_id="activate-" + request_id)
return data
def buckets(m, sub_account_id):
with m.factory() as s:
return (s.get(Account, int(m.account_id)).balance_point_units,
s.get(SubAccount, sub_account_id).balance_point_units)
def month_usage(m, scope_id, month=None):
with m.factory() as s:
row = s.scalar(select(ScopeMonthUsage).where(
ScopeMonthUsage.scope_id == scope_id,
ScopeMonthUsage.month == (month or current_month())))
return 0 if row is None else row.used_point_units
def funded_account(m):
assert gift(m).status_code == 200
def employee_world(m, tag, *, points=500):
"""A funded account with one budgeted sub-account, one store keyed to it and
one employee of that store, plus the employee's own call key."""
sub_id = int(create_sub_account(m, m.account_id, "budget-" + tag,
request_id="create-sub-" + tag).json()["data"]["subAccountId"])
assert transfer(m, m.account_id, sub_id, points, "OUT", request_id="transfer-" + tag).status_code == 200
store_id = int(bind_store(m, m.account_id, "mei1:store-" + tag, sub_id,
request_id="bind-store-" + tag).json()["data"]["scopeId"])
employee_id = int(bind_employee(m, m.account_id, "mei1:emp-" + tag, "mei1:store-" + tag,
request_id="bind-emp-" + tag).json()["data"]["scopeId"])
secret = issue_scope_key(m, m.account_id, employee_id, request_id="issue-emp-key-" + tag)["secret"]
return sub_id, store_id, employee_id, secret
def call_employee(m, request_id, secret, business_ref):
return chat(m, request_id=request_id, key=secret, body=chat_body(businessRef=business_ref))
def test_store_key_is_globally_unique_and_format_checked(management):
m = management
account_id = open_account(m)
first = bind_store(m, account_id, "mei1:store-001", request_id="bind-store-0001")
assert first.status_code == 200, first.text
scope = first.json()["data"]
assert scope["scopeType"] == "STORE" and scope["status"] == "ACTIVE"
assert scope["subAccountId"] is None and scope["parentScopeId"] is None
replay = bind_store(m, account_id, "mei1:store-001", request_id="bind-store-0001")
assert replay.status_code == 200 and replay.json()["data"]["scopeId"] == scope["scopeId"]
conflict = bind_store(m, account_id, "mei1:store-001", request_id="bind-store-0002")
assert conflict.status_code == 409 and conflict.json()["code"] == "SCOPE_CONFLICT"
# A store key exists once, so even another account cannot bind it - that is
# what keeps "one store lives under one account" structural.
other = open_account(m, source="client-b", request_id="open-account-b1")
cross = bind_store(m, other, "mei1:store-001", request_id="bind-store-b1", source="client-b")
assert cross.status_code == 409 and cross.json()["code"] == "SCOPE_CONFLICT"
# The key must carry its source namespace.
for bad_key in ("store-001", "mei1:", "MEI1:store-001"):
assert bind_store(m, account_id, bad_key, request_id="bind-bad-%s" % bad_key).status_code in (400, 422)
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(Scope)) == 1
def test_employee_binding_requires_a_live_store_of_the_same_account(management):
m = management
account_id = open_account(m)
store_id = int(bind_store(m, account_id, "mei1:store-0011",
request_id="bind-store-0011").json()["data"]["scopeId"])
shapeless = post(m, SCOPE_PATH % account_id, {"scopeType": "EMPLOYEE", "scopeKey": "mei1:emp-001"},
request_id="bind-emp-0001")
assert shapeless.status_code in (400, 422)
unknown = bind_employee(m, account_id, "mei1:emp-001", "mei1:store-999", request_id="bind-emp-0002")
assert unknown.status_code == 404 and unknown.json()["code"] == "REQUEST_NOT_FOUND"
employee = bind_employee(m, account_id, "mei1:emp-001", "mei1:store-0011", request_id="bind-emp-0003")
assert employee.status_code == 200, employee.text
assert employee.json()["data"]["parentScopeId"] == str(store_id)
nested = bind_employee(m, account_id, "mei1:emp-002", "mei1:emp-001", request_id="bind-emp-0004")
assert nested.status_code == 404
assert scope_status(m, account_id, store_id, "DISABLED", request_id="scope-status-0001").status_code == 200
closed = bind_employee(m, account_id, "mei1:emp-003", "mei1:store-0011", request_id="bind-emp-0005")
assert closed.status_code == 403 and closed.json()["code"] == "SCOPE_DISABLED"
other = open_account(m, source="client-b", request_id="open-account-b2")
bind_store(m, other, "mei1:store-900", request_id="bind-store-b2", source="client-b")
cross = bind_employee(m, other, "mei1:emp-900", "mei1:store-0011", request_id="bind-emp-b2",
source="client-b")
assert cross.status_code == 404
def test_scope_key_matrix_snapshots_and_charging_buckets(calling):
m = calling
funded_account(m)
account_id = m.account_id
sub_id = int(create_sub_account(m, account_id, "budget-matrix",
request_id="create-sub-matrix").json()["data"]["subAccountId"])
assert transfer(m, account_id, sub_id, 500, "OUT", request_id="transfer-matrix").status_code == 200
store_id = int(bind_store(m, account_id, "mei1:store-m1", sub_id,
request_id="bind-store-m1").json()["data"]["scopeId"])
direct_id = int(bind_store(m, account_id, "mei1:store-m2", None,
request_id="bind-store-m2").json()["data"]["scopeId"])
employee_id = int(bind_employee(m, account_id, "mei1:emp-m1", "mei1:store-m1",
request_id="bind-emp-m1").json()["data"]["scopeId"])
sub_key = issue_sub_key(m, account_id, sub_id, request_id="issue-sub-key-m1")["secret"]
store_key = issue_scope_key(m, account_id, store_id, request_id="issue-store-key-m1")["secret"]
direct_key = issue_scope_key(m, account_id, direct_id, request_id="issue-direct-key-m1")["secret"]
employee_key = issue_scope_key(m, account_id, employee_id, request_id="issue-emp-key-m1")["secret"]
month = current_month()
def charge(name, key):
request_id = "matrix-" + name
before = buckets(m, sub_id)
response = chat(m, request_id=request_id, key=key, body=chat_body(businessRef=name))
assert response.status_code == 200, response.text
units = int(response.json()["data"]["consumedPointUnits"])
after = buckets(m, sub_id)
return units, (before[0] - after[0], before[1] - after[1]), stored_call(m, request_id)
units, delta, row = charge("account", m.secret)
assert delta == (units, 0)
assert (row.sub_account_id, row.scope_id, row.store_scope_id) == (None, None, None)
assert row.quota_month == month
units, delta, row = charge("sub", sub_key)
assert delta == (0, units)
assert (row.sub_account_id, row.scope_id, row.store_scope_id) == (sub_id, None, None)
units, delta, row = charge("store", store_key)
assert delta == (0, units)
assert (row.sub_account_id, row.scope_id, row.store_scope_id) == (sub_id, store_id, store_id)
# A store with no sub-account of its own drains the account's unallocated
# bucket - the path that exists for a merchant holding one account only.
units, delta, row = charge("direct", direct_key)
assert delta == (units, 0)
assert (row.sub_account_id, row.scope_id, row.store_scope_id) == (None, direct_id, direct_id)
units, delta, row = charge("employee", employee_key)
assert delta == (0, units)
assert (row.sub_account_id, row.scope_id, row.store_scope_id) == (sub_id, employee_id, store_id)
assert month_usage(m, employee_id) == units
def test_employee_quota_blocks_admission_without_touching_the_bucket(calling):
m = calling
funded_account(m)
sub_id, store_id, employee_id, secret = employee_world(m, "q1")
assert set_quota(m, m.account_id, employee_id, store_id, 1000, request_id="quota-q1a").status_code == 200
first = call_employee(m, "quota-call-q1a", secret, "quota-first")
assert first.status_code == 200, first.text
units = int(first.json()["data"]["consumedPointUnits"])
assert units > 0 and month_usage(m, employee_id) == units
before = buckets(m, sub_id)
# The limit now sits at or below what this month already consumed.
limit_points = units // 10000
assert set_quota(m, m.account_id, employee_id, store_id, limit_points,
request_id="quota-q1b").status_code == 200
blocked = call_employee(m, "quota-call-q1b", secret, "quota-blocked")
assert blocked.status_code == 429, blocked.text
assert blocked.json()["code"] == "EMPLOYEE_QUOTA_EXCEEDED"
payload = blocked.json()["data"]
assert payload["scopeId"] == str(employee_id) and payload["quotaMonth"] == current_month()
assert payload["usedPointUnits"] == str(units)
assert payload["limitPointUnits"] == str(limit_points * 10000)
# Nothing was reserved and nothing was registered: the call never happened.
assert buckets(m, sub_id) == before
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(Call).where(
Call.request_id == "quota-call-q1b")) == 0
assert s.scalar(select(func.count()).select_from(Consumption)) == 1
# Lifting the limit above the month's usage lets the very same employee back in.
assert set_quota(m, m.account_id, employee_id, store_id, 1000, request_id="quota-q1c").status_code == 200
again = call_employee(m, "quota-call-q1c", secret, "quota-allowed")
assert again.status_code == 200, again.text
assert month_usage(m, employee_id) == units + int(again.json()["data"]["consumedPointUnits"])
def test_month_resets_usage_but_keeps_enforcing_the_configured_limit(calling, monkeypatch):
m = calling
funded_account(m)
sub_id, store_id, employee_id, secret = employee_world(m, "mo1")
assert set_quota(m, m.account_id, employee_id, store_id, 1, request_id="quota-mo1a").status_code == 200
with m.factory.begin() as s:
repository.upsert_month_usage(s, employee_id, current_month(), 10000)
# Spent exactly to the limit: the month is closed without any reset job.
blocked = call_employee(m, "month-call-mo1a", secret, "month-blocked")
assert blocked.status_code == 429 and blocked.json()["code"] == "EMPLOYEE_QUOTA_EXCEEDED"
monkeypatch.setattr("app.account_calls._quota_month", lambda now: "2031-07")
fresh = call_employee(m, "month-call-mo1b", secret, "month-next")
assert fresh.status_code == 200, fresh.text
# The new month starts from zero with the limit untouched, and the in-flight
# month stamp follows the registration month.
assert stored_call(m, "month-call-mo1b").quota_month == "2031-07"
assert month_usage(m, employee_id, "2031-07") == int(fresh.json()["data"]["consumedPointUnits"])
assert month_usage(m, employee_id, current_month()) == 10000
def test_employee_usage_follows_across_stores_and_the_new_store_limit_applies(calling):
m = calling
funded_account(m)
sub_id, store_a, employee_id, secret = employee_world(m, "mv1", points=500)
store_b = int(bind_store(m, m.account_id, "mei1:store-mv2", sub_id,
request_id="bind-store-mv2").json()["data"]["scopeId"])
assert set_quota(m, m.account_id, employee_id, store_a, 1000, request_id="quota-mv1a").status_code == 200
first = call_employee(m, "move-call-mv1a", secret, "move-first")
assert first.status_code == 200, first.text
units = int(first.json()["data"]["consumedPointUnits"])
assert month_usage(m, employee_id) == units
moved = move_employee(m, m.account_id, employee_id, store_b, request_id="move-emp-mv1")
assert moved.status_code == 200, moved.text
assert moved.json()["data"]["previousParentScopeId"] == str(store_a)
# Moving stores never wipes the month: the employee carries what it consumed.
assert month_usage(m, employee_id) == units
# The new store has no quota row of its own, and the limit is whatever the
# store the employee belongs to now configures: unconfigured means no limit
# there. The old store's value is deliberately *not* carried over (decision
# 2026-09-30) - usage is what crosses the boundary, not the configuration.
unconfigured = call_employee(m, "move-call-mv1b", secret, "move-unconfigured")
assert unconfigured.status_code == 200, unconfigured.text
assert stored_call(m, "move-call-mv1b").store_scope_id == store_b
carried = units + int(unconfigured.json()["data"]["consumedPointUnits"])
assert month_usage(m, employee_id) == carried
# Configuring the new store below the carried-over usage closes the month at
# once; raising it lets the same employee back in.
assert set_quota(m, m.account_id, employee_id, store_b, units // 10000,
request_id="quota-mv1c").status_code == 200
blocked = call_employee(m, "move-call-mv1c", secret, "move-blocked")
assert blocked.status_code == 429 and blocked.json()["code"] == "EMPLOYEE_QUOTA_EXCEEDED"
assert set_quota(m, m.account_id, employee_id, store_b, 1000, request_id="quota-mv1d").status_code == 200
allowed = call_employee(m, "move-call-mv1d", secret, "move-allowed")
assert allowed.status_code == 200, allowed.text
assert month_usage(m, employee_id) == carried + int(allowed.json()["data"]["consumedPointUnits"])
def test_store_reassignment_moves_no_balance_and_takes_effect_at_once(calling):
m = calling
funded_account(m)
account_id = m.account_id
sub_a = int(create_sub_account(m, account_id, "budget-ra",
request_id="create-sub-ra").json()["data"]["subAccountId"])
sub_b = int(create_sub_account(m, account_id, "budget-rb",
request_id="create-sub-rb").json()["data"]["subAccountId"])
for sub_id in (sub_a, sub_b):
assert transfer(m, account_id, sub_id, 400, "OUT",
request_id="transfer-%s" % sub_id).status_code == 200
store_id = int(bind_store(m, account_id, "mei1:store-ra", sub_a,
request_id="bind-store-ra").json()["data"]["scopeId"])
key = issue_scope_key(m, account_id, store_id, request_id="issue-store-key-ra")["secret"]
def charge(request_id):
# (account, sub_a, sub_b) balance deltas plus the consumption itself:
# the same account bucket is read once, so no slot is double counted.
with m.factory() as s:
before = (s.get(Account, int(m.account_id)).balance_point_units,
s.get(SubAccount, sub_a).balance_point_units,
s.get(SubAccount, sub_b).balance_point_units)
response = chat(m, request_id=request_id, key=key, body=chat_body(businessRef=request_id))
assert response.status_code == 200, response.text
units = int(response.json()["data"]["consumedPointUnits"])
with m.factory() as s:
after = (s.get(Account, int(m.account_id)).balance_point_units,
s.get(SubAccount, sub_a).balance_point_units,
s.get(SubAccount, sub_b).balance_point_units)
return (before[0] - after[0], before[1] - after[1], before[2] - after[2], units)
first = charge("ra-call-1")
assert first[:3] == (0, first[3], 0)
with m.factory() as s:
assert s.get(SubAccount, sub_a).balance_point_units == 400 * 10000 - first[3]
reassign = move_store(m, account_id, store_id, sub_b, request_id="move-store-ra")
assert reassign.status_code == 200, reassign.text
assert reassign.json()["data"]["subAccountId"] == str(sub_b)
assert reassign.json()["data"]["previousSubAccountId"] == str(sub_a)
replay = move_store(m, account_id, store_id, sub_b, request_id="move-store-ra")
assert replay.status_code == 200 and replay.json()["data"]["subAccountId"] == str(sub_b)
second = charge("ra-call-2")
assert second[:3] == (0, 0, second[3])
assert stored_call(m, "ra-call-2").sub_account_id == sub_b
# Detaching the store puts it straight onto the account's unallocated bucket.
assert move_store(m, account_id, store_id, None, request_id="move-store-ra2").status_code == 200
third = charge("ra-call-3")
assert third[:3] == (third[3], 0, 0)
assert stored_call(m, "ra-call-3").sub_account_id is None
# Reassignment itself never moves money: only the two transfers and the
# three consumptions exist.
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(PointRecord).where(
PointRecord.type.in_(("TRANSFER_OUT", "TRANSFER_IN")))) == 4
assert s.scalar(select(func.count()).select_from(PointRecord).where(
PointRecord.type == "CONSUME")) == 3
def test_disabled_scopes_stop_admission_but_never_move_a_sibling(calling):
m = calling
funded_account(m)
account_id = m.account_id
sub_id, store_id, employee_id, secret = employee_world(m, "ds1")
assert set_quota(m, account_id, employee_id, store_id, 1000, request_id="quota-ds1a").status_code == 200
disabled = scope_status(m, account_id, store_id, "DISABLED", request_id="scope-status-ds1a")
assert disabled.status_code == 200 and disabled.json()["data"]["fromStatus"] == "ACTIVE"
blocked = call_employee(m, "ds-call-1", secret, "ds-disabled-store")
assert blocked.status_code == 403 and blocked.json()["code"] == "SCOPE_DISABLED"
assert blocked.json()["data"]["parentScopeId"] == str(store_id)
# A disabled scope cannot be handed a new key either.
denied = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"scopeId": str(store_id)},
request_id="issue-store-key-ds1b")
assert denied.status_code == 403 and denied.json()["code"] == "SCOPE_DISABLED"
assert scope_status(m, account_id, store_id, "ACTIVE", request_id="scope-status-ds1b").status_code == 200
assert call_employee(m, "ds-call-2", secret, "ds-store-back").status_code == 200
# Disabling the employee closes only that employee: account-level keys keep
# working (the store's own key is a sibling subject, not a parent).
assert scope_status(m, account_id, employee_id, "DISABLED", request_id="scope-status-ds1c").status_code == 200
assert call_employee(m, "ds-call-3", secret, "ds-disabled-employee").status_code == 403
assert chat(m, request_id="ds-call-4", body=chat_body(businessRef="ds-account-key")).status_code == 200
def test_quota_edit_is_idempotent_audited_and_conflict_checked(calling):
m = calling
funded_account(m)
sub_id, store_id, employee_id, secret = employee_world(m, "qe1")
first = set_quota(m, m.account_id, employee_id, store_id, 50, request_id="quota-qe1a")
assert first.status_code == 200, first.text
assert first.json()["data"]["monthlyQuotaPointUnits"] == str(50 * 10000)
replay = set_quota(m, m.account_id, employee_id, store_id, 50, request_id="quota-qe1a")
assert replay.status_code == 200
assert replay.json()["data"]["quotaId"] == first.json()["data"]["quotaId"]
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(ScopeQuota)) == 1
changed = set_quota(m, m.account_id, employee_id, store_id, 60, request_id="quota-qe1a")
assert changed.status_code == 409 and changed.json()["code"] == "FINGERPRINT_MISMATCH"
# An edit applies to the current month at once, with before/after evidence in
# the audit trail (a historical month's effective quota is replayed from exactly
# these rows, since no limit row is snapshotted on the call itself).
second = set_quota(m, m.account_id, employee_id, store_id, 70, request_id="quota-qe1b")
assert second.status_code == 200 and second.json()["data"]["monthlyQuotaPointUnits"] == str(700000)
with m.factory() as s:
audit = s.scalar(select(OperationAudit).where(OperationAudit.request_id == "quota-qe1b"))
quota = s.scalar(select(ScopeQuota).where(ScopeQuota.scope_id == employee_id))
assert audit.action == "SET_SCOPE_QUOTA" and audit.target_id == quota.id
assert audit.evidence_ref["beforeQuotaPointUnits"] == str(500000)
assert audit.evidence_ref["afterQuotaPointUnits"] == str(700000)
assert audit.evidence_ref["rowLastUpdateTime"] == quota.last_update_time.isoformat(
timespec="milliseconds").replace("+00:00", "Z")
# NULL is an explicit "unlimited" that is terminal: it never falls back.
assert set_quota(m, m.account_id, employee_id, store_id, None, request_id="quota-qe1c").status_code == 200
with m.factory.begin() as s:
repository.upsert_month_usage(s, employee_id, current_month(), 10 ** 12)
assert call_employee(m, "qe-call-1", secret, "qe-unlimited").status_code == 200
assert set_quota(m, m.account_id, employee_id, store_id, 0, request_id="quota-qe1d").status_code == 200
assert call_employee(m, "qe-call-2", secret, "qe-zero").status_code == 429
def test_month_usage_upsert_accumulates_and_is_unique_per_month(management):
m = management
account_id = open_account(m)
scope_id = int(bind_store(m, account_id, "mei1:store-up", request_id="bind-store-up").json()["data"]["scopeId"])
with m.factory.begin() as s:
repository.upsert_month_usage(s, scope_id, "2031-01", 500)
with m.factory.begin() as s:
repository.upsert_month_usage(s, scope_id, "2031-01", 700)
repository.upsert_month_usage(s, scope_id, "2031-02", 100)
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(ScopeMonthUsage)) == 2
assert s.scalar(select(ScopeMonthUsage.used_point_units).where(
ScopeMonthUsage.scope_id == scope_id, ScopeMonthUsage.month == "2031-01")) == 1200
assert s.scalar(select(ScopeMonthUsage.used_point_units).where(
ScopeMonthUsage.scope_id == scope_id, ScopeMonthUsage.month == "2031-02")) == 100
with m.factory.begin() as s:
repository.upsert_month_usage(s, scope_id, "2031-01", 0)
repository.upsert_month_usage(s, scope_id, "2031-01", -25)
with m.factory() as s:
assert s.scalar(select(ScopeMonthUsage.used_point_units).where(
ScopeMonthUsage.scope_id == scope_id, ScopeMonthUsage.month == "2031-01")) == 1200
def test_scope_pagination_filters_and_account_isolation(management):
m = management
account_id = open_account(m)
bind_store(m, account_id, "mei1:store-p1", request_id="bind-store-p1")
bind_store(m, account_id, "mei1:store-p2", request_id="bind-store-p2")
bind_employee(m, account_id, "mei1:emp-p1", "mei1:store-p1", request_id="bind-emp-p1")
listing = m.client.get(SCOPE_PATH % account_id + "?page=1&size=10&scopeType=STORE",
headers=headers(m))
assert listing.status_code == 200, listing.text
data = listing.json()["data"]
assert data["total"] == 2 and {row["scopeType"] for row in data["list"]} == {"STORE"}
employees = m.client.get(SCOPE_PATH % account_id + "?scopeType=EMPLOYEE", headers=headers(m))
assert employees.json()["data"]["total"] == 1
assert m.client.get(SCOPE_PATH % account_id, headers=headers(m, "client-b")).status_code == 404
def test_account_level_key_is_platform_only_but_subject_keys_stay_merchant_issued(management):
m = management
account_id = open_account(m)
# A merchant cannot hand itself the account's unallocated bucket (2026-09-30):
# the merchant path is a store/employee key, platform issues account-level keys.
denied = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {},
request_id="key-policy-0001")
assert denied.status_code == 403
assert denied.json()["code"] == "PERMISSION_DENIED"
assert denied.json()["data"]["reason"] == "ACCOUNT_KEY_PLATFORM_ONLY"
shapeless = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {},
request_id="key-policy-0001", source="platform")
assert shapeless.status_code == 400
created = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"clientId": "client-a"},
request_id="key-policy-0002", source="platform")
assert created.status_code == 200, created.text
with m.factory() as s:
row = s.get(Credential, int(created.json()["data"]["credentialId"]))
# The platform issued it, but the key is still client-a's own key.
assert row.client_id == "client-a" and row.sub_account_id is None and row.scope_id is None
# Subject keys (子账号/门店/员工) remain the merchant's to issue.
sub_id = create_sub_account(m, account_id, "budget-key",
request_id="create-sub-key").json()["data"]["subAccountId"]
assert issue_sub_key(m, account_id, sub_id, request_id="issue-sub-key-key")["status"] == "PENDING"
store_id = bind_store(m, account_id, "mei1:store-key", sub_id, request_id="bind-store-key")
assert store_id.status_code == 200, store_id.text
assert issue_scope_key(m, account_id, int(store_id.json()["data"]["scopeId"]),
request_id="issue-store-key-key")["status"] == "PENDING"
def test_openai_error_maps_employee_quota_to_insufficient_quota():
response = openai_error(AccountError("EMPLOYEE_QUOTA_EXCEEDED", data={
"scopeId": "7", "quotaMonth": "2031-07", "limitPointUnits": "10000", "usedPointUnits": "10000"}))
assert response.status_code == 429
payload = json.loads(response.body)
assert payload["error"]["type"] == "insufficient_quota"
assert payload["error"]["code"] == "EMPLOYEE_QUOTA_EXCEEDED"
assert payload["error"]["mei1"]["quotaMonth"] == "2031-07"
"""分账守恒 E2E(段 3d):真实调用穿过结算后,账本/月账/桶流水必须互相对得上。
覆盖 `scripts/sql/check.sql` B 部分第 9/11/13/15 节;MySQL 分支同时执行
交付的完整检查 SQL,防止 ORM 断言通过但 SQL 口径仍然误报。
"""
from sqlalchemy import func, select
from app.account_ledger import page_consumptions, page_month_usage
from app.account_models import Consumption, PointRecord
from tests.test_account_calls import calling, chat, chat_body, gift # noqa: F401 (fixture/helpers)
from tests.test_account_foundation import account_engine, target_check_anomalies # noqa: F401
from tests.test_account_management import management # noqa: F401 (fixture plumbing)
from tests.test_account_scope import (bind_employee, bind_store, buckets,
issue_scope_key, month_usage, set_quota)
from tests.test_account_sub_account import create_sub_account, transfer
def _charge(m, request_id, key):
response = chat(m, request_id=request_id, key=key, body=chat_body(businessRef=request_id))
assert response.status_code == 200, response.text
return int(response.json()["data"]["consumedPointUnits"])
def _ledger(m, account_id, **filters):
return page_consumptions(m.service, m.keys["client-a"], account_ids=[account_id], size=200, **filters)
def _sum(result):
return sum(int(row["consumedPointUnits"]) for row in result["list"])
def test_scoped_money_matches_between_ledger_month_bucket_and_point_records(calling):
m = calling
account_id = m.account_id
# Use the gift flow so the delivered SQL can verify the opening balance too.
response = gift(m)
assert response.status_code == 200, response.text
sub_response = create_sub_account(m, account_id, "e2e-budget", request_id="e2e-sub-0001")
assert sub_response.status_code == 200, sub_response.text
sub_id = int(sub_response.json()["data"]["subAccountId"])
assert transfer(m, account_id, sub_id, 500, "OUT", request_id="e2e-transfer").status_code == 200
store_a = int(bind_store(m, account_id, "mei1:e2e-store-a", sub_id,
request_id="e2e-store-a").json()["data"]["scopeId"])
store_b = int(bind_store(m, account_id, "mei1:e2e-store-b",
request_id="e2e-store-b").json()["data"]["scopeId"])
employee = int(bind_employee(m, account_id, "mei1:e2e-emp", "mei1:e2e-store-a",
request_id="e2e-emp-0001").json()["data"]["scopeId"])
assert set_quota(m, account_id, employee, store_a, 100, request_id="e2e-quota").status_code == 200
employee_key = issue_scope_key(m, account_id, employee, request_id="e2e-emp-key")["secret"]
store_a_key = issue_scope_key(m, account_id, store_a, request_id="e2e-store-a-key")["secret"]
store_b_key = issue_scope_key(m, account_id, store_b, request_id="e2e-store-b-key")["secret"]
account_before, bucket_before = buckets(m, sub_id)
employee_units = [_charge(m, "e2e-emp-%s" % tag, employee_key) for tag in ("01", "02")]
store_a_units = _charge(m, "e2e-store-a-01", store_a_key)
store_b_units = _charge(m, "e2e-store-b-01", store_b_key)
account_units = _charge(m, "e2e-account-01", m.secret)
account_after, bucket_after = buckets(m, sub_id)
# 1) 每笔消费带自己的计费主体(分账口径)。
rows = {row["requestId"]: row for row in _ledger(m, account_id)["list"]}
assert len(rows) == 5
for tag in ("01", "02"):
row = rows["e2e-emp-%s" % tag]
assert row["scopeId"] == str(employee) and row["storeScopeId"] == str(store_a)
assert row["subAccountId"] == str(sub_id)
assert rows["e2e-store-a-01"]["scopeId"] == rows["e2e-store-a-01"]["storeScopeId"] == str(store_a)
assert rows["e2e-store-b-01"]["subAccountId"] is None
assert rows["e2e-account-01"]["scopeId"] is None and rows["e2e-account-01"]["storeScopeId"] is None
# 2) 门店账 = 该店自身 + 其下员工;直挂门店与账户级调用不进这一档。
store_ledger = _ledger(m, account_id, store_scope_ids=[store_a])
assert store_ledger["total"] == 3
assert _sum(store_ledger) == sum(employee_units) + store_a_units
assert _sum(_ledger(m, account_id, scope_ids=[employee])) == sum(employee_units)
# 3) 月账单 = 员工当月真实消耗,且带生效额度;额度与实际口径同源。
month = _ledger(m, account_id)["list"][0]["createTime"][:7].replace("-", "-")
usage = page_month_usage(m.service, m.keys["client-a"], account_ids=[account_id], size=200)
assert usage["total"] == 1
row = usage["list"][0]
assert row["scopeId"] == str(employee) and row["storeScopeId"] == str(store_a)
assert row["usedPointUnits"] == str(sum(employee_units)) == str(month_usage(m, employee))
assert row["limitPointUnits"] == "1000000" and row["unlimited"] is False
assert month == row["month"]
# 4) 桶守恒:有桶的调用扣子账号桶,直挂门店与账户级调用扣账户未分配余额。
assert bucket_before - bucket_after == sum(employee_units) + store_a_units
assert account_before - account_after == store_b_units + account_units
# 5) 对账 #15:消费按桶聚合 = CONSUME 流水的同桶聚合(账户桶与子账号桶各自成立)。
with m.factory() as session:
consumption = dict(session.execute(
select(Consumption.sub_account_id, func.sum(Consumption.consumed_point_units))
.where(Consumption.settlement_status == "SUCCESS").group_by(Consumption.sub_account_id)).all())
records = dict(session.execute(
select(PointRecord.sub_account_id, func.sum(-PointRecord.point_units))
.where(PointRecord.type == "CONSUME").group_by(PointRecord.sub_account_id)).all())
assert consumption == records
assert consumption[None] == store_b_units + account_units
assert consumption[sub_id] == sum(employee_units) + store_a_units
if m.engine.dialect.name == "mysql":
with m.factory() as session:
assert target_check_anomalies(session) == set()
import asyncio
import json
import pytest
from sqlalchemy import func, select
from app.account_models import Account, Call, Consumption, Credential, PointRecord, SubAccount
from tests.test_account_calls import calling, chat, chat_body, gift, stored_call # noqa: F401 (fixtures/helpers)
from tests.test_account_foundation import account_engine # noqa: F401 (fixture plumbing)
from tests.test_account_management import activate, headers, issue, management, open_account, post
def m_engine_is_sqlite(m):
return m.engine.dialect.name == "sqlite"
def create_sub_account(m, account_id, name, *, request_id="create-sub-0001", remark=None, source=None):
body = {"name": name}
if remark:
body["remark"] = remark
return post(m, "/internal/v1/accounts/%s/sub-accounts" % account_id, body,
request_id=request_id, **({"source": source} if source else {}))
def transfer(m, account_id, sub_account_id, points, direction, *,
request_id="transfer-0001", note=None, source=None):
body = {"points": points, "direction": direction}
if note:
body["operatorNote"] = note
return post(m, "/internal/v1/accounts/%s/sub-accounts/%s/transfers" % (account_id, sub_account_id),
body, request_id=request_id, **({"source": source} if source else {}))
def sub_account_status(m, account_id, sub_account_id, status, *, request_id="sub-status-001"):
return post(m, "/internal/v1/accounts/%s/sub-accounts/%s/status" % (account_id, sub_account_id),
{"status": status, "reason": "test"}, request_id=request_id)
def issue_sub_key(m, account_id, sub_account_id, *, request_id="issue-sub-key-001"):
response = post(m, "/internal/v1/accounts/%s/credentials" % account_id,
{"subAccountId": str(sub_account_id)}, request_id=request_id)
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["status"] == "PENDING" and data["secretAvailable"]
activate(m, data, request_id="activate-" + request_id)
return data
def test_create_sub_account_is_idempotent_and_scoped(management):
m = management
account_id = open_account(m)
first = create_sub_account(m, account_id, "budget-a")
assert first.status_code == 200, first.text
sub = first.json()["data"]
assert sub["balancePointUnits"] == "0" and sub["status"] == "ACTIVE"
replay = create_sub_account(m, account_id, "budget-a")
assert replay.status_code == 200 and replay.json()["data"]["subAccountId"] == sub["subAccountId"]
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(SubAccount)) == 1
duplicate_name = create_sub_account(m, account_id, "budget-a", request_id="create-sub-0002")
assert duplicate_name.status_code == 503
# Same name under another account is allowed; client-a has no standing on
# client-b's account.
other = open_account(m, source="client-b", request_id="open-account-b1")
assert create_sub_account(m, other, "budget-a").status_code == 404
assert create_sub_account(m, other, "budget-a", source="client-b",
request_id="create-sub-b9").status_code == 200
# Fingerprint conflict on the same request id with different name.
conflict = create_sub_account(m, account_id, "budget-z", request_id="create-sub-0001")
assert conflict.status_code == 409 and conflict.json()["code"] == "FINGERPRINT_MISMATCH"
def test_transfer_moves_balance_and_writes_paired_records(management):
m = management
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"])
with m.factory() as s:
account = s.get(Account, int(account_id))
account.balance_point_units = 1_000_000_000
s.commit()
out = transfer(m, account_id, sub_id, 5, "OUT", note="allocate")
assert out.status_code == 200, out.text
assert out.json()["data"]["balancePointUnits"] == str(5 * 10000)
with m.factory() as s:
account = s.get(Account, int(account_id))
sub = s.get(SubAccount, sub_id)
assert account.balance_point_units == 1_000_000_000 - 50000
assert sub.balance_point_units == 50000
records = s.scalars(select(PointRecord).where(
PointRecord.type.in_(("TRANSFER_OUT", "TRANSFER_IN"))).order_by(PointRecord.id)).all()
assert len(records) == 2
out_rec, in_rec = records
assert out_rec.point_units == -50000 and out_rec.sub_account_id is None
assert in_rec.point_units == 50000 and in_rec.sub_account_id == sub_id
assert out_rec.request_id == in_rec.request_id
# Each record tracks its own bucket: account 1e9 -> 999950000, sub 0 -> 50000.
assert (out_rec.balance_before_units, out_rec.balance_after_units) == (1_000_000_000, 1_000_000_000 - 50000)
assert (in_rec.balance_before_units, in_rec.balance_after_units) == (0, 50000)
# Replay returns the same result without double-crediting.
replay = transfer(m, account_id, sub_id, 5, "OUT")
assert replay.status_code == 200 and replay.json()["data"]["balancePointUnits"] == str(50000)
with m.factory() as s:
assert s.scalar(select(func.count()).select_from(PointRecord).where(
PointRecord.type == "TRANSFER_IN")) == 1
# Back-flow: sub -> account.
back = transfer(m, account_id, sub_id, 2, "IN", request_id="transfer-0002")
assert back.status_code == 200
with m.factory() as s:
assert s.get(SubAccount, sub_id).balance_point_units == 30000
assert s.get(Account, int(account_id)).balance_point_units == 1_000_000_000 - 30000
def test_transfer_rejects_insufficient_balance_without_partial_move(management):
m = management
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"])
with m.factory() as s:
account = s.get(Account, int(account_id))
account.balance_point_units = 10000 # 1 point
s.commit()
response = transfer(m, account_id, sub_id, 5, "OUT")
assert response.status_code == 409
body = response.json()
assert body["code"] == "ACCOUNT_BLOCKED" and body["data"]["reason"] == "INSUFFICIENT_BALANCE"
assert body["data"]["subAccountId"] == str(sub_id)
with m.factory() as s:
assert s.get(Account, int(account_id)).balance_point_units == 10000
assert s.get(SubAccount, sub_id).balance_point_units == 0
assert s.scalar(select(func.count()).select_from(PointRecord).where(
PointRecord.type.in_(("TRANSFER_OUT", "TRANSFER_IN")))) == 0
# Same request id cannot be reused after the rejection: the failed attempt
# registered nothing, so a retry with corrected amounts succeeds.
retry = transfer(m, account_id, sub_id, 1, "OUT")
assert retry.status_code == 200, retry.text
def test_transfer_fingerprint_conflict_and_unknown_sub_account(management):
m = management
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"])
with m.factory() as s:
s.get(Account, int(account_id)).balance_point_units = 1_000_000_000
s.commit()
assert transfer(m, account_id, sub_id, 5, "OUT").status_code == 200
conflict = transfer(m, account_id, sub_id, 6, "OUT")
assert conflict.status_code == 409 and conflict.json()["code"] == "FINGERPRINT_MISMATCH"
missing = transfer(m, account_id, 999999, 5, "OUT", request_id="transfer-0009")
assert missing.status_code == 404 and missing.json()["code"] == "SUB_ACCOUNT_NOT_FOUND"
cross = open_account(m, source="client-b", request_id="open-account-b2")
other_sub = int(create_sub_account(m, cross, "budget-b", source="client-b",
request_id="create-sub-b1").json()["data"]["subAccountId"])
foreign = transfer(m, cross, other_sub, 1, "OUT", source="client-a", request_id="transfer-0010")
assert foreign.status_code == 404
def test_sub_account_status_split_from_creation_and_blocks_calls(management):
m = management
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"])
disabled = sub_account_status(m, account_id, sub_id, "DISABLED")
assert disabled.status_code == 200, disabled.text
with m.factory() as s:
assert s.get(SubAccount, sub_id).status == "DISABLED"
# Creation replay after disabling returns the creation result untouched.
replay = create_sub_account(m, account_id, "budget-a")
assert replay.status_code == 200 and replay.json()["data"]["status"] == "ACTIVE"
enabled = sub_account_status(m, account_id, sub_id, "ACTIVE", request_id="sub-status-002")
assert enabled.status_code == 200
with m.factory() as s:
assert s.get(SubAccount, sub_id).status == "ACTIVE"
def test_sub_account_key_fingerprint_includes_subject(management):
m = management
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"])
first = issue(m, account_id, request_id="issue-key-001", source="platform", client_id="client-a")
assert first["status"] == "PENDING" and first["secretAvailable"]
sub_key = issue_sub_key(m, account_id, sub_id, request_id="issue-key-002")
assert sub_key["credentialId"] != first["credentialId"]
# Same request id with a different subject is a fingerprint conflict, not a replay.
conflict = post(m, "/internal/v1/accounts/%s/credentials" % account_id,
{"subAccountId": str(sub_id), "clientId": "client-a"}, request_id="issue-key-001",
source="platform")
assert conflict.status_code == 409 and conflict.json()["code"] == "FINGERPRINT_MISMATCH"
# Legacy replay without a subject still returns the original key metadata.
replay = post(m, "/internal/v1/accounts/%s/credentials" % account_id, {"clientId": "client-a"},
request_id="issue-key-001", source="platform")
assert replay.status_code == 200 and replay.json()["data"]["credentialId"] == first["credentialId"]
# A subject bound to another account is rejected.
other = open_account(m, source="client-b", request_id="open-account-b3")
assert post(m, "/internal/v1/accounts/%s/credentials" % other,
{"subAccountId": str(sub_id)}, request_id="issue-key-003").status_code == 404
with m.factory() as s:
row = s.get(Credential, int(sub_key["credentialId"]))
assert row.sub_account_id == sub_id and row.scope_id is None
def test_sub_account_list_and_account_view(management):
m = management
account_id = open_account(m)
sub_a = create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"]
sub_b = create_sub_account(m, account_id, "budget-b", request_id="create-sub-0011").json()["data"]["subAccountId"]
listed = m.client.get("/internal/v1/accounts/%s/sub-accounts" % account_id, headers=headers(m))
assert listed.status_code == 200
data = listed.json()["data"]
assert data["total"] == 2 and {row["subAccountId"] for row in data["list"]} == {sub_a, sub_b}
other = m.client.get("/internal/v1/accounts/%s/sub-accounts" % account_id,
headers=headers(m, "client-b"))
assert other.status_code == 404
def test_concurrent_transfer_allows_only_one_winner(management):
if m_engine_is_sqlite(management):
pytest.skip("SQLite 不提供行锁证明")
m = management
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "budget-a").json()["data"]["subAccountId"])
with m.factory() as s:
s.get(Account, int(account_id)).balance_point_units = 100000 # 10 points
s.commit()
results = []
def attempt(index):
response = transfer(m, account_id, sub_id, 10, "OUT", request_id="transfer-race-%d" % index)
results.append((response.status_code, response.json().get("code")))
import threading
threads = [threading.Thread(target=attempt, args=(index,)) for index in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
ok = [status for status, _ in results if status == 200]
assert len(ok) == 1, results
with m.factory() as s:
assert s.get(SubAccount, sub_id).balance_point_units == 100000
assert s.get(Account, int(account_id)).balance_point_units == 0
assert s.scalar(select(func.count()).select_from(PointRecord).where(
PointRecord.type == "TRANSFER_IN")) == 1
def test_sub_account_key_call_charges_only_sub_bucket(calling):
from tests.test_account_calls import GIFT_UNITS
m = calling
assert gift(m).status_code == 200
sub_id = int(create_sub_account(m, m.account_id, "budget-a",
request_id="create-sub-0021").json()["data"]["subAccountId"])
assert transfer(m, m.account_id, sub_id, 100, "OUT", request_id="transfer-0021").status_code == 200
sub_key = issue_sub_key(m, m.account_id, sub_id, request_id="issue-sub-key-0021")
response = chat(m, request_id="sub-bucket-call-001", key=sub_key["secret"],
body=chat_body(businessRef="sub-bucket"))
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["billingStatus"] == "SUCCESS" and data["settled"] is True
row = stored_call(m, "sub-bucket-call-001")
assert row.sub_account_id == sub_id and row.scope_id is None
consumed = data["consumedPointUnits"]
with m.factory() as s:
sub = s.get(SubAccount, sub_id)
account = s.get(Account, int(m.account_id))
assert sub.balance_point_units == 100 * 10000 - int(consumed)
# The unallocated bucket keeps exactly the post-transfer amount: the
# call charged the sub bucket only.
assert account.balance_point_units == GIFT_UNITS - 100 * 10000
consumption = s.get(Consumption, row.consumption_record_id)
assert consumption.sub_account_id == sub_id
point = s.scalar(select(PointRecord).where(
PointRecord.type == "CONSUME", PointRecord.sub_account_id == sub_id))
assert point is not None and point.call_id == row.id
assert (point.balance_before_units, point.balance_after_units) == (100 * 10000, 100 * 10000 - int(consumed))
# Replay with the same request id keeps bucket semantics.
replay = chat(m, request_id="sub-bucket-call-001", key=sub_key["secret"],
body=chat_body(businessRef="sub-bucket"))
assert replay.json()["data"]["callId"] == data["callId"]
with m.factory() as s:
assert s.get(SubAccount, sub_id).balance_point_units == 100 * 10000 - int(consumed)
def test_sub_account_key_rejected_when_sub_disabled_or_empty(calling):
m = calling
assert gift(m).status_code == 200
sub_id = int(create_sub_account(m, m.account_id, "budget-a",
request_id="create-sub-0022").json()["data"]["subAccountId"])
key = issue_sub_key(m, m.account_id, sub_id, request_id="issue-sub-key-0022")
# Zero-balance sub-account blocks the call even though the unallocated bucket is funded.
rejected = chat(m, request_id="sub-empty-call-001", key=key["secret"])
assert rejected.status_code == 409
body = rejected.json()
assert body["code"] == "ACCOUNT_BLOCKED" and body["data"]["reason"] == "INSUFFICIENT_BALANCE"
assert body["data"]["subAccountId"] == str(sub_id)
# Funding the bucket unblocks; disabling the sub-account blocks again.
assert transfer(m, m.account_id, sub_id, 1, "OUT", request_id="transfer-0022").status_code == 200
assert sub_account_status(m, m.account_id, sub_id, "DISABLED",
request_id="sub-status-0022").status_code == 200
disabled = chat(m, request_id="sub-disabled-call-001", key=key["secret"])
assert disabled.status_code == 403 and disabled.json()["code"] == "SUB_ACCOUNT_DISABLED"
"""月账单直读(段 3b):`scope_month_usage` 分页 + 生效额度 + 门店/员工/子账号维度。
Rows are inserted directly, in the style of tests/test_account_ledger_scope.py.
"""
import pytest
from app.account_ledger import page_month_usage
from app.account_security import AccountError
from tests.test_account_foundation import account_engine # noqa: F401 (fixture plumbing)
from tests.test_account_management import management, open_account
from tests.test_account_scope import bind_employee, bind_store, month_usage, set_quota
from tests.test_account_sub_account import create_sub_account
CURRENT = "2026-09"
EARLIER = "2026-08"
def _usage(m, scope_id, month, units):
from app.account_models import ScopeMonthUsage
with m.factory.begin() as session:
session.add(ScopeMonthUsage(scope_id=scope_id, month=month, used_point_units=units))
assert month_usage(m, scope_id, month) == units
def _world(m):
"""Two stores under one bucket, three employees (one unlimited), usage rows
in the current and in an earlier month, and a store keyed straight to the
account so the bucket filter has something to exclude."""
account_id = open_account(m)
sub_id = int(create_sub_account(m, account_id, "usage-budget",
request_id="usage-sub").json()["data"]["subAccountId"])
store_a = int(bind_store(m, account_id, "mei1:usage-store-a", sub_id,
request_id="usage-store-a").json()["data"]["scopeId"])
store_b = int(bind_store(m, account_id, "mei1:usage-store-b",
request_id="usage-store-b").json()["data"]["scopeId"])
employees = [int(bind_employee(m, account_id, "mei1:usage-emp-%s" % tag, store,
request_id="usage-emp-" + tag).json()["data"]["scopeId"])
for tag, store in (("1", "mei1:usage-store-a"), ("2", "mei1:usage-store-a"),
("3", "mei1:usage-store-b"))]
assert set_quota(m, account_id, employees[0], store_a, 100,
request_id="usage-quota-1").status_code == 200
_usage(m, employees[0], CURRENT, 25000000)
_usage(m, employees[0], EARLIER, 10000000)
_usage(m, employees[1], CURRENT, 45000000)
_usage(m, employees[2], CURRENT, 15000000)
return account_id, sub_id, store_a, store_b, employees
def _page(m, **kwargs):
return page_month_usage(m.service, m.keys["client-a"], **kwargs)
def test_month_usage_page_reports_usage_scope_and_effective_limit(management):
m = management
account_id, sub_id, store_a, store_b, employees = _world(m)
result = _page(m, account_ids=[account_id], month=CURRENT, size=200)
assert (result["total"], result["month"]) == (3, CURRENT)
# Highest consumer first, then by id: deterministic paging without a sort key.
assert [row["scopeId"] for row in result["list"]] == [str(value) for value in
[employees[1], employees[0], employees[2]]]
rows = {row["scopeId"]: row for row in result["list"]}
budgeted = rows[str(employees[0])]
assert budgeted["scopeType"] == "EMPLOYEE" and budgeted["scopeKey"] == "mei1:usage-emp-1"
assert budgeted["storeScopeId"] == str(store_a) and budgeted["storeScopeKey"] == "mei1:usage-store-a"
assert budgeted["subAccountId"] == str(sub_id)
assert budgeted["month"] == CURRENT
assert budgeted["usedPointUnits"] == "25000000" and budgeted["usedPoints"] == "2500.0000"
assert budgeted["limitPointUnits"] == "1000000" and budgeted["limitPoints"] == "100.0000"
assert budgeted["unlimited"] is False
assert budgeted["scopeStatus"] == "ACTIVE" and budgeted["storeStatus"] == "ACTIVE"
# Never configured at this store: unlimited, and the bucket is the account's.
unbudgeted = rows[str(employees[1])]
assert (unbudgeted["limitPointUnits"], unbudgeted["limitPoints"], unbudgeted["unlimited"]) == (None, None, True)
assert unbudgeted["usedPointUnits"] == "45000000"
direct = rows[str(employees[2])]
assert direct["subAccountId"] is None and direct["storeScopeId"] == str(store_b)
def test_month_usage_keeps_months_apart_and_leaves_history_limitless(management):
m = management
account_id, sub_id, store_a, store_b, employees = _world(m)
earlier = _page(m, account_ids=[account_id], month=EARLIER, size=200)
assert earlier["total"] == 1 and earlier["month"] == EARLIER
row = earlier["list"][0]
assert row["scopeId"] == str(employees[0]) and row["usedPointUnits"] == "10000000"
# A past month's effective quota can only be replayed from the audit trail
# (design §3.3), so this endpoint reports no limit for history at all.
assert (row["limitPointUnits"], row["limitPoints"], row["unlimited"]) == (None, None, None)
# The current month is a different page with its own rows.
assert _page(m, account_ids=[account_id], month=CURRENT)["total"] == 3
def test_month_usage_filters_by_store_employee_and_bucket(management):
m = management
account_id, sub_id, store_a, store_b, employees = _world(m)
store_page = _page(m, account_ids=[account_id], month=CURRENT, store_scope_ids=[store_a])
assert {row["scopeId"] for row in store_page["list"]} == {str(employees[0]), str(employees[1])}
single = _page(m, account_ids=[account_id], month=CURRENT, scope_ids=[employees[2]])
assert [row["scopeId"] for row in single["list"]] == [str(employees[2])]
bucket = _page(m, account_ids=[account_id], month=CURRENT, sub_account_ids=[sub_id])
assert {row["scopeId"] for row in bucket["list"]} == {str(employees[0]), str(employees[1])}
both = _page(m, account_ids=[account_id], month=CURRENT, store_scope_ids=[store_a],
scope_ids=[employees[0]])
assert [row["scopeId"] for row in both["list"]] == [str(employees[0])]
def test_month_usage_pages_without_gaps_and_stays_inside_the_account(management):
m = management
account_id, sub_id, store_a, store_b, employees = _world(m)
seen = []
for page in range(1, 3):
result = _page(m, account_ids=[account_id], month=CURRENT, page=page, size=2)
assert (result["total"], result["page"], result["size"]) == (3, page, 2)
seen.extend(row["scopeId"] for row in result["list"])
assert seen == [str(value) for value in [employees[1], employees[0], employees[2]]]
outside = _page(m, account_ids=[account_id], month=CURRENT, store_scope_ids=[store_b],
scope_ids=[employees[0]])
assert (outside["total"], outside["list"]) == (0, [])
def test_month_usage_refuses_call_keys_and_bad_months(management):
m = management
account_id, sub_id, store_a, store_b, employees = _world(m)
from tests.test_account_scope import issue_scope_key
secret = issue_scope_key(m, account_id, store_a, request_id="usage-store-key")["secret"]
with pytest.raises(AccountError) as error:
page_month_usage(m.service, secret, month=CURRENT)
assert error.value.code == "PERMISSION_DENIED"
with pytest.raises(AccountError) as error:
_page(m, account_ids=[account_id], month="2026-13")
assert error.value.code == "INVALID_ARGUMENT"
with pytest.raises(AccountError) as error:
page_month_usage(m.service, m.keys["platform"], month=CURRENT)
assert error.value.code == "INVALID_ARGUMENT"
# INTEGRATION without an explicit target reads the accounts it owns - same
# rule as the ledger pages, and it cannot see past that ownership.
own = _page(m, month=CURRENT)
assert own["total"] == 3
assert {row["scopeId"] for row in own["list"]} == {str(value) for value in employees}
"""审查发现的三项问题——回归护栏(2026-09-30 修复后断言目标行为)。
修复前的缺陷行为(复现证据)见交付说明;这里断言的是修复后的目标行为:
- P1-a 可用性查询与调用准入共用主体判定:同一个 Key,`GET /api/v1/account` /
`/api/v1/account/availability` 的 available 必须与真实调用结果一致;子账号桶、
门店/员工状态、员工月度额度都纳入判定;`balancePointUnits` 是**该 Key 实际扣费桶**
的余额(账户级 Key 仍是账户未分配桶)。
- P1-b 发号器使用独立连接池:`db_pool_size=1`(合法配置)下,冷号段的写请求也必须
成功,不得占用业务池的第二条连接。
- P2 凭证列表支持分页 + status/subAccountId/scopeId 筛选,出参补 scopeId/
subAccountId/scopeType。
"""
from dataclasses import replace
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import account_main
from app.account_main import create_account_app
from app.account_models import Account, Call, Credential
from tests.test_account_calls import CallGateway, chat
from tests.test_account_foundation import account_engine # noqa: F401 (fixture plumbing)
from tests.test_account_management import ( # noqa: F401 (fixture plumbing)
ID_LOW, activate, headers, issue, management, open_account, post,
)
from tests.test_account_scope import bind_employee, bind_store, issue_scope_key
from tests.test_account_scope import move_employee
from tests.test_account_scope import set_quota as scope_quota
from tests.test_account_sub_account import create_sub_account, transfer
ACCOUNT_PATH = "/internal/v1/accounts"
POINT_UNITS = 10000
def use_call_gateway(m):
"""management fixture 的 FakeGateway 只有开户相关方法;调用路径需要 CallGateway。"""
m.gateway = CallGateway(m)
m.service.gateway = m.gateway
return m
def credit(m, account_id, points=146):
"""直接给账户未分配桶记余额(绕过赠送入口,避免依赖赠送专用的假网关)。
146 积分 = 1,460,000 子单位,便于把账户桶精确划空。
"""
units = points * POINT_UNITS
with m.factory.begin() as session:
session.get(Account, int(account_id), with_for_update=True).balance_point_units = units
return units
def availability(m, key):
response = m.client.post("/api/v1/account/availability", json={},
headers=headers(m, request_id="avail-request-01", key=key))
assert response.status_code == 200, response.text
return response.json()["data"]
def issue_sub_account_key(m, account_id, sub_account_id, request_id="issue-sub-key-01"):
response = post(m, ACCOUNT_PATH + "/%s/credentials" % account_id,
{"subAccountId": str(sub_account_id)}, request_id=request_id)
assert response.status_code == 200, response.text
data = response.json()["data"]
activate(m, data, request_id=request_id + "-act")
return data["secret"]
def test_p1a_availability_matches_admission_for_sub_account_key(management):
"""子账号 Key:可用性与准入必须一致,余额反映它真正扣的那个桶。"""
m = management
use_call_gateway(m)
account_id = open_account(m)
credit(m, account_id, 146)
sub_id = int(create_sub_account(m, account_id, "review-bucket",
request_id="review-sub-00001").json()["data"]["subAccountId"])
key = issue_sub_account_key(m, account_id, sub_id)
# 子账号桶 0(账户桶有余额)→ 不可用,与调用的 409 一致。
empty = availability(m, key)
assert empty["available"] is False
assert "INSUFFICIENT_BALANCE" in empty["blockedReasons"]
assert empty["subAccountId"] == str(sub_id)
assert empty["bucketType"] == "SUB_ACCOUNT"
assert empty["bucketId"] == str(sub_id)
assert empty["balancePointUnits"] == "0"
blocked = chat(m, request_id="review-call-0001", key=key)
assert blocked.status_code == 409, blocked.text
assert blocked.json()["data"]["reason"] == "INSUFFICIENT_BALANCE"
assert blocked.json()["data"]["subAccountId"] == str(sub_id)
# 余额划给子账号(账户未分配桶归零)→ 可用,与调用成功一致。
assert transfer(m, account_id, sub_id, 146, "OUT",
request_id="review-transfer-11").status_code == 200
funded = availability(m, key)
assert funded["available"] is True
assert funded["blockedReasons"] == []
assert funded["balancePointUnits"] == "1460000"
succeeded = chat(m, request_id="review-call-0002", key=key)
assert succeeded.status_code == 200, succeeded.text
assert succeeded.json()["data"]["billingStatus"] == "SUCCESS"
def test_p1a_availability_covers_scope_status_and_employee_quota(management):
"""门店状态、门店连带员工下线、员工月度额度都参与可用性判定。"""
m = management
use_call_gateway(m)
account_id = open_account(m)
credit(m, account_id, 146)
sub_id = int(create_sub_account(m, account_id, "review-bucket-2",
request_id="review-sub-00002").json()["data"]["subAccountId"])
assert transfer(m, account_id, sub_id, 100, "OUT",
request_id="review-transfer-22").status_code == 200
store = bind_store(m, account_id, "review:store-2", sub_id, request_id="review-store-02")
assert store.status_code == 200, store.text
store_id = store.json()["data"]["scopeId"]
store_key = issue_scope_key(m, account_id, store_id, request_id="review-store-key2")["secret"]
employee = bind_employee(m, account_id, "review:emp-2", "review:store-2",
request_id="review-emp-00002")
employee_id = employee.json()["data"]["scopeId"]
staff_key = issue_scope_key(m, account_id, employee_id, request_id="review-emp-key02")["secret"]
live = availability(m, store_key)
assert live["available"] is True and live["scopeId"] == store_id
assert live["bucketType"] == "SUB_ACCOUNT" and live["bucketId"] == str(sub_id)
assert live["balancePointUnits"] == "1000000"
# 停用门店:门店 Key 与它的员工 Key 都必须变成不可用。
assert post(m, ACCOUNT_PATH + "/%s/scopes/%s/status" % (account_id, store_id),
{"status": "DISABLED", "reason": "review"},
request_id="review-store-off").status_code == 200
offline = availability(m, store_key)
assert offline["available"] is False
assert "SCOPE_DISABLED" in offline["blockedReasons"]
disabled = chat(m, request_id="review-call-0003", key=store_key)
assert disabled.status_code == 403, disabled.text
assert disabled.json()["code"] == "SCOPE_DISABLED"
staff_offline = availability(m, staff_key)
assert staff_offline["available"] is False
assert "SCOPE_DISABLED" in staff_offline["blockedReasons"]
assert chat(m, request_id="review-call-0005", key=staff_key).status_code == 403
# 员工额度 0(挂在未停用的门店上):不可用,与 429 一致。
live_store = bind_store(m, account_id, "review:store-3", sub_id, request_id="review-store-03")
live_id = live_store.json()["data"]["scopeId"]
limited = bind_employee(m, account_id, "review:emp-3", "review:store-3",
request_id="review-emp-00003")
limited_id = limited.json()["data"]["scopeId"]
limited_key = issue_scope_key(m, account_id, limited_id, request_id="review-emp-key03")["secret"]
assert scope_quota(m, account_id, limited_id, live_id, 0,
request_id="review-quota-001").status_code == 200
over = availability(m, limited_key)
assert over["available"] is False
assert "EMPLOYEE_QUOTA_EXCEEDED" in over["blockedReasons"]
assert chat(m, request_id="review-call-0004", key=limited_key).status_code == 429
def test_p1a_account_level_key_keeps_the_unallocated_bucket(management):
"""账户级 CALL Key 口径不变:可用性看账户未分配桶。"""
m = management
use_call_gateway(m)
account_id = open_account(m)
credit(m, account_id, 146)
sub_id = int(create_sub_account(m, account_id, "review-bucket-3",
request_id="review-sub-00003").json()["data"]["subAccountId"])
assert transfer(m, account_id, sub_id, 146, "OUT",
request_id="review-transfer-33").status_code == 200
# 账户级 CALL Key 仅平台可签(2026-09-30 决策②)。
credential = issue(m, account_id, request_id="issue-acct-key-1", source="platform",
client_id="client-a")
activate(m, credential, request_id="activate-acct-key")
account_key = credential["secret"]
view = availability(m, account_key)
assert view["available"] is False
assert "INSUFFICIENT_BALANCE" in view["blockedReasons"]
assert view["balancePointUnits"] == "0"
assert view["bucketType"] == "ACCOUNT" and view["bucketId"] is None
assert view["subAccountId"] is None and view["scopeId"] is None
assert chat(m, request_id="review-call-0006", key=account_key).status_code == 409
def test_p1b_cold_segment_succeeds_with_a_single_connection_pool(management, monkeypatch):
"""发号器独立连接池:池容量 1 + 冷号段的写请求必须成功。
修复前:写事务持有唯一连接后,发号器再向同一个池申请连接 → 5 秒后 503。
"""
m = management
if m.engine.dialect.name != "mysql":
pytest.skip("只在真实 MySQL 上验证连接池行为")
async def close():
pass
m.gateway.aclose = close
monkeypatch.setattr(account_main, "ModelGateway", lambda settings: m.gateway)
base = replace(m.service.settings,
database_url=m.engine.url.render_as_string(hide_password=False))
def create(name, size, request_id):
settings = replace(base, db_pool_size=size, db_max_overflow=0)
with TestClient(create_account_app(settings=settings)) as client:
cold = client.post(ACCOUNT_PATH, json={"name": name},
headers=headers(m, request_id=request_id))
service = client.app.state.service
# 发号器不与业务共用 sessionmaker(即不共用连接池)
assert service.generator._session_factory is not service.factory
return cold
cold = create("Pool-One", 1, "pool-one-00001")
assert cold.status_code == 200, cold.text
assert cold.json()["data"]["provisionStatus"] == "READY"
def test_p1a_reservation_gate_and_unresolved_call_enter_the_query(management):
"""赠送门闩与未核清调用:查询侧与准入侧给出同一结论(I-01 验收)。"""
m = management
use_call_gateway(m)
account_id = open_account(m)
credit(m, account_id, 146)
credential = issue(m, account_id, request_id="issue-gate-key-1", source="platform",
client_id="client-a")
activate(m, credential, request_id="activate-gate-key")
key = credential["secret"]
assert availability(m, key)["available"] is True
with m.factory.begin() as session:
session.get(Account, int(account_id)).gift_gate = 1 # 门闩非空即视为在途赠送
gated = availability(m, key)
assert gated["available"] is False and "GIFT_GATE_BUSY" in gated["blockedReasons"]
assert chat(m, request_id="review-call-0007", key=key).status_code == 409
with m.factory.begin() as session:
account = session.get(Account, int(account_id))
account.gift_gate = None
session.add(Call(id=100_000_000_000_700_001, account_id=int(account_id),
client_id="client-a", business_code="CHAT", business_ref="review-open",
request_id="review-open-0001", fingerprint="0" * 64,
execution_status="UNKNOWN", billing_status="UNKNOWN",
gateway_error_snapshot={"binding": {}}, version=1))
uncertain = availability(m, key)
assert uncertain["available"] is False and "UNRESOLVED_CALL" in uncertain["blockedReasons"]
blocked = chat(m, request_id="review-call-0008", key=key)
assert blocked.status_code == 409, blocked.text
assert blocked.json()["code"] == "ACCOUNT_BLOCKED"
assert blocked.json()["data"]["reason"] == "UNRESOLVED_CALL"
def test_p1a_employee_move_follows_the_new_store_rule(management):
"""改派:员工限额按当前门店,可用性查询也要跟着当前门店走(I-01 验收)。"""
m = management
use_call_gateway(m)
account_id = open_account(m)
credit(m, account_id, 146)
sub_id = int(create_sub_account(m, account_id, "review-bucket-5",
request_id="review-sub-00005").json()["data"]["subAccountId"])
assert transfer(m, account_id, sub_id, 100, "OUT",
request_id="review-transfer-55").status_code == 200
first = bind_store(m, account_id, "review:store-a", sub_id, request_id="review-store-a")
first_id = first.json()["data"]["scopeId"]
second = bind_store(m, account_id, "review:store-b", sub_id, request_id="review-store-b")
second_id = second.json()["data"]["scopeId"]
employee = bind_employee(m, account_id, "review:emp-move", "review:store-a",
request_id="review-emp-move")
employee_id = employee.json()["data"]["scopeId"]
key = issue_scope_key(m, account_id, employee_id, request_id="review-emp-move-key")["secret"]
assert scope_quota(m, account_id, employee_id, first_id, 0,
request_id="review-quota-a").status_code == 200
assert scope_quota(m, account_id, employee_id, second_id, 5_000_000,
request_id="review-quota-b").status_code == 200
# 在 A 店(零额度)→ 不可用;改派到 B 店(额度充足)→ 立即可用。
assert availability(m, key)["available"] is False
moved = move_employee(m, account_id, employee_id, second_id, request_id="review-move-01")
assert moved.status_code == 200, moved.text
after = availability(m, key)
assert after["available"] is True, after["blockedReasons"]
def test_p1b_exhausted_segment_and_replay_without_generator(management, monkeypatch):
"""号段边界与幂等重放(I-02 验收):号段用尽仍能写;重放不依赖发号器。"""
m = management
if m.engine.dialect.name != "mysql":
pytest.skip("只在真实 MySQL 上验证连接池行为")
async def close():
pass
m.gateway.aclose = close
monkeypatch.setattr(account_main, "ModelGateway", lambda settings: m.gateway)
settings = replace(m.service.settings,
database_url=m.engine.url.render_as_string(hide_password=False),
db_pool_size=1, db_max_overflow=0)
with TestClient(create_account_app(settings=settings)) as client:
service = client.app.state.service
# 冷启动:号段为空 → 第一次写请求就要分配新号段
opened = client.post(ACCOUNT_PATH, json={"name": "Segment-Cold"},
headers=headers(m, request_id="segment-cold-01"))
assert opened.status_code == 200, opened.text
account_id = opened.json()["data"]["accountId"]
# 号段边界:把进程内号段标记为已用尽,下一个写请求必须再分配一次
service.generator._cursor = service.generator._segment_end
boundary = client.post(ACCOUNT_PATH + "/%s/sub-accounts" % account_id,
json={"name": "边界子账号"},
headers=headers(m, request_id="segment-bound-01"))
assert boundary.status_code == 200, boundary.text
# 幂等重放:即便发号器完全不可用,已存在的请求仍可重放(回查不依赖号段)
service.generator._cursor = service.generator._segment_end
service.generator._session_factory = _broken_factory
replay = client.post(ACCOUNT_PATH, json={"name": "Segment-Cold"},
headers=headers(m, request_id="segment-cold-01"))
assert replay.status_code == 200, replay.text
assert replay.json()["data"]["accountId"] == account_id
def _broken_factory():
raise RuntimeError("发号器不可用(测试用)")
def _seed_credentials(m, account_id, count, revoked_from):
with m.factory.begin() as session:
for offset in range(count):
session.add(Credential(
id=100_000_000_000_900_000 + offset, category="CALL", client_id="client-a",
account_id=int(account_id), secret_digest="%064x" % (offset + 1),
secret_mask="abcd********wxyz",
status="REVOKED" if offset >= revoked_from else "ACTIVE", issued_by=ID_LOW + 3,
))
def test_p2_credential_list_pages_past_two_hundred(management):
"""215 把 Key(95 把已撤销)→ 分页可取,不再整表 400。"""
m = management
account_id = open_account(m)
_seed_credentials(m, account_id, 215, revoked_from=120)
first = m.client.get(ACCOUNT_PATH + "/%s/credentials?page=1&size=20" % account_id,
headers=headers(m, request_id="list-cred-00001"))
assert first.status_code == 200, first.text
page = first.json()["data"]
assert page["total"] == 215 and len(page["list"]) == 20
assert page["page"] == 1 and page["size"] == 20
ids = [int(row["credentialId"]) for row in page["list"]]
assert ids == sorted(ids, reverse=True), "按 create_time/id 倒序"
last = m.client.get(ACCOUNT_PATH + "/%s/credentials?page=11&size=20" % account_id,
headers=headers(m, request_id="list-cred-00002"))
assert last.status_code == 200, last.text
assert len(last.json()["data"]["list"]) == 15
capped = m.client.get(ACCOUNT_PATH + "/%s/credentials?size=1000" % account_id,
headers=headers(m, request_id="list-cred-00003"))
assert capped.status_code == 400, capped.text
assert capped.json()["code"] == "INVALID_ARGUMENT"
def test_p2_credential_list_filters_by_status_and_subject(management):
"""status / subAccountId / scopeId 三个筛选维度。"""
m = management
account_id = open_account(m)
_seed_credentials(m, account_id, 130, revoked_from=100)
sub_id = int(create_sub_account(m, account_id, "review-bucket-4",
request_id="review-sub-00004").json()["data"]["subAccountId"])
issue_sub_account_key(m, account_id, sub_id, request_id="review-sub-key-04")
store = bind_store(m, account_id, "review:store-4", sub_id, request_id="review-store-04")
store_id = store.json()["data"]["scopeId"]
issue_scope_key(m, account_id, store_id, request_id="review-store-key4")
revoked = m.client.get(ACCOUNT_PATH + "/%s/credentials?status=REVOKED" % account_id,
headers=headers(m, request_id="list-cred-00004"))
assert revoked.status_code == 200, revoked.text
assert revoked.json()["data"]["total"] == 30
assert {row["status"] for row in revoked.json()["data"]["list"]} == {"REVOKED"}
only_sub = m.client.get(ACCOUNT_PATH + "/%s/credentials?subAccountId=%s" % (account_id, sub_id),
headers=headers(m, request_id="list-cred-00005"))
assert only_sub.status_code == 200, only_sub.text
rows = only_sub.json()["data"]["list"]
assert [row["subAccountId"] for row in rows] == [str(sub_id)]
only_store = m.client.get(ACCOUNT_PATH + "/%s/credentials?scopeId=%s" % (account_id, store_id),
headers=headers(m, request_id="list-cred-00006"))
assert only_store.status_code == 200, only_store.text
scoped = only_store.json()["data"]["list"]
assert [row["scopeId"] for row in scoped] == [store_id]
assert scoped[0]["scopeType"] == "STORE"
# 门店 Key 只绑 scope_id;它扣哪个桶由 scope 归属决定,不落在凭证行上。
assert scoped[0]["subAccountId"] is None
def test_p2_credential_list_keeps_the_source_constraint(management):
"""分页/筛选不改动权限约束:非平台 Key 不能查别的来源(I-03 验收)。"""
m = management
account_id = open_account(m)
denied = m.client.get(ACCOUNT_PATH + "/%s/credentials?clientId=client-z" % account_id,
headers=headers(m, request_id="list-cred-00008"))
assert denied.status_code == 403, denied.text
assert denied.json()["code"] == "PERMISSION_DENIED"
def test_p2_credential_view_identifies_the_bound_subject(management):
"""出参补 scopeId / subAccountId / scopeType,无需再翻库。"""
m = management
account_id = open_account(m)
store = bind_store(m, account_id, "review:store-9", None, request_id="review-store-09")
store_id = store.json()["data"]["scopeId"]
issued = issue_scope_key(m, account_id, store_id, request_id="review-store-key9")
assert issued["scopeId"] == store_id and issued["scopeType"] == "STORE"
assert issued["subAccountId"] is None
response = m.client.get(ACCOUNT_PATH + "/%s/credentials" % account_id,
headers=headers(m, request_id="list-cred-00007"))
assert response.status_code == 200, response.text
row = next(item for item in response.json()["data"]["list"]
if item["credentialId"] == issued["credentialId"])
assert row["scopeId"] == store_id and row["scopeType"] == "STORE"
with m.factory() as session:
bound = session.scalars(select(Credential).where(Credential.scope_id == int(store_id))).all()
assert bound, "库里确实记了 scope_id,接口现在也要回出来"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment