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 @@
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/db.py:create_mysql_engine`:显式 READ COMMITTED、UTC、严格模式;由调用方显式传入连接,尚未改部署配置。
- `app/id_generator.py`:新库调用方显式选择独立 IdSegment;种子只向前推进,fork 子进程重建发号锁并丢弃余段。调用方必须在子进程内创建 Engine/Session 工厂,不复用父进程连接池或 Session。
......@@ -17,7 +17,7 @@ P1 数据底座、P2 开户/凭证、P3 赠送/账单与 P4 模型调用/结算
`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
python -m app.account_main --config /path/outside-project/account.json
......@@ -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 细化。
- [账户层级与门店/员工限额设计](docs/account-hierarchy-and-scope-limits-design.md):段 1–3 的设计与取舍(子账号/门店/员工/额度/分账/月账单)。
- [独立化开发计划](docs/independent-account-development-plan.md):P0–P8、验收、迁移和单写切流。
- [旧设计](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/20260923_account_source_precheck.sql` | 旧 SaaS 库,只读检查实际结构、来源、孤儿关联、余额、未清状态与高水位 |
| `scripts/20260923_account_target_check.sql` | 新独立库,只读检查余额、调用/消费/流水关联、门闩和号段 |
| `scripts/sql/schema.sql` | 空的独立算力库,创建当前 15 张表及索引、约束;由 ORM 生成 |
| `scripts/sql/check.sql` | 当前独立库,只读核对账本、余额桶、员工月用量、关联和号段 |
| `scripts/sql/saas/` | SaaS 接入/迁移材料,按实际对接方案单独核对;不随网关初始化执行 |
| `scripts/sql/archive/` | 历史基线、增量及旧检查原文,仅供追溯 |
MySQL 最低 8.0.16,目标版本上线前须确认。DDL 不使用 IF NOT EXISTS,部分失败必须先检查,不使用客户端 `--force` 继续执行。迁移需停旧分配器,复制旧已提交高水位;只有真正空的新安装才允许从 100000000000000000 初始化。不能用 MAX(id)+1,不能在未知数据情况下直接执行低位种子。
本阶段不提供自动导入/一键切流;实际历史数据和 SaaS 映射必须先经过源库预检。已经执行过的 `20260922_computing_stage0_ddl.sql` 不改动。
建库、号段初始化、结果判读和开发变更流程见 [SQL 使用说明](scripts/sql/README.md)。最低 MySQL 8.0.16;全量 DDL 不用于升级已有数据库,不使用 `--force` 跳过错误。脚本不自动建库、授权、导入数据或重置号段。
## 开发验证
......@@ -81,10 +83,10 @@ MySQL 最低 8.0.16,目标版本上线前须确认。DDL 不使用 IF NOT EXIS
```bash
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 最终验收或迁移上线通过。
......
"""调用主体准入判定:可用性查询与真实调用共用同一套规则。
修复前 `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
from sqlalchemy.exc import IntegrityError
from . import account_repository as repository
from . import account_admission as admission
from .account_config import _valid_model
from .account_execution import run_database
from .account_gateway import GatewayError
from .account_model_gateway import StreamInterrupted
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_settlement import SettlementService
from .billing import EXPECTED_QUOTA_PER_UNIT, format_points
......@@ -28,6 +29,26 @@ def _string(value):
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):
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
......@@ -96,21 +117,17 @@ class CallService:
"tokenName": account.gateway_token_name, "model": model,
"quotaPerUnit": snapshot.get("quotaPerUnit")}
def _admit(self, session, account, model):
if account.status != "ACTIVE":
raise AccountError("ACCOUNT_DISABLED")
def _subject(self, session, principal, month):
"""Resolve the credential's scope, if any, into the calling subject."""
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)
if (account.provision_status != "READY" or not binding["tokenId"] or not binding["tokenName"]
or binding["gatewayId"] != self.settings.gateway_identity
or binding["quotaPerUnit"] != EXPECTED_QUOTA_PER_UNIT
or (account.gateway_snapshot or {}).get("model") != model):
raise AccountError("DEPENDENCY_UNAVAILABLE")
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")
verdict = admission.evaluate(session, account, sub_account_id=sub_account_id,
subject=subject, month=month, for_update=True,
binding_error=admission.binding_error(account, self.settings, model))
if verdict.error is not None:
raise verdict.error
return binding
def _state(self, row):
......@@ -118,15 +135,20 @@ class CallService:
"version": row.version, "binding": row.gateway_error_snapshot["binding"],
"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,
"businessRef": body.business_ref, "gatewayParams": gateway_params,
"messages": [message.model_dump() for message in body.messages]}
if stream_options is not None:
values["stream"] = stream_options
digest = fingerprint(values)
with self.factory() as session:
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)
if original is not None:
if original.fingerprint != digest:
......@@ -147,12 +169,19 @@ class CallService:
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):
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)
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,
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})
session.add(row)
session.flush()
......@@ -175,7 +204,9 @@ class CallService:
raise AccountError("REQUEST_NOT_FOUND")
if row.dispatch_phase != "REGISTERED" or row.billing_status != "PROCESSING" or row.version != state["version"]:
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"]:
raise AccountError("DEPENDENCY_UNAVAILABLE")
_remaining(deadline)
......
......@@ -84,6 +84,8 @@ class AccountSettings:
newapi_model: str = field(repr=False)
db_pool_size: int = 5
db_max_overflow: int = 0
db_generator_pool_size: int = 2
db_generator_max_overflow: int = 2
management_timeout_seconds: float = 30.0
management_concurrency: int = 8
connect_timeout_seconds: float = 10.0
......@@ -115,6 +117,11 @@ class AccountSettings:
raise ValueError
if type(self.db_max_overflow) is not int or self.db_max_overflow < 0:
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
or not 0 < self.management_concurrency <= 100):
raise ValueError
......
......@@ -10,7 +10,10 @@ from starlette.concurrency import run_in_threadpool
from . import account_repository as repository
from .account_gateway import GatewayError
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_service import fingerprint, _time
from .billing import (
......@@ -112,6 +115,17 @@ class GiftService:
"operator_note": body.operator_note})
for attempt in range(2):
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:
account = repository.lock_account(session, int(body.account_id))
actor = self._platform(session, secret)
......@@ -121,9 +135,6 @@ class GiftService:
if row.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH")
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)
row = Gift(id=ids[0], account_id=account.id, client_id=body.client_id,
actor_credential_id=actor.credential_id, request_id=request_id,
......@@ -327,18 +338,44 @@ class GiftService:
def _reconcile_state(self, secret, request_id, gift_id, body):
with self.factory() as session:
actor = self._platform(session, secret)
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")
# 幂等行先查、gift 行后读:并发同 requestId 的对账里,另一个事务可能刚提交
# 了这笔对账,而镜像里 gift 行还是提交前的版本。先读 gift 会把"已成功"的
# 重放回执配上过期视图(version 与 phase 倒退),这里改为命中重放后重读。
previous = repository.find_management_request(session, actor.client_id, "RECONCILE", request_id)
if previous is not None:
if previous.fingerprint != self._reconcile_digest(gift_id, body):
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)
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)
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)
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):
if state["version"] != body.expected_version:
raise AccountError("ACCOUNT_BLOCKED", reason="VERSION_CONFLICT")
......
"""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.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 .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):
# Retain the projected account so authentication does not fetch its gateway metadata.
......@@ -60,6 +70,8 @@ def _consumption_view(row):
"accountId": units_json(row.account_id), "clientId": row.client_id,
"requestId": row.request_id, "businessCode": row.business_code, "businessRef": row.business_ref,
"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,
"billingStatus": row.billing_status, "settled": row.settled_flag,
"settlementSource": row.settlement_source,
......@@ -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:
require_management(principal)
if subjects:
# 分账维度只存在于消费账本;赠送没有门店/员工/分桶主体。
raise AccountError("INVALID_ARGUMENT")
if principal.category == "CALL":
# 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")
elif principal.category == "INTEGRATION":
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):
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)]
if client_id is not None:
conditions.append(model.client_id == client_id)
for name, values in subjects:
conditions.append(getattr(model, name).in_(values))
if start is not None:
conditions.append(model.create_time >= start)
if end is not None:
......@@ -137,7 +154,8 @@ def _filters(model, scope, client_id, start, end, request_id):
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(
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,
......@@ -146,7 +164,7 @@ def _gifts(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(
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,
......@@ -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.consumed_point_units, Call.create_time, Call.last_update_time,
Call.complete_time, Consumption.settled_time,
Call.sub_account_id, Call.scope_id, Call.store_scope_id,
).outerjoin(Consumption, and_(
Consumption.id == Call.consumption_record_id, Consumption.call_id == Call.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(
Consumption.id, Consumption.call_id, Consumption.id.label("consumption_record_id"),
Consumption.account_id, Consumption.client_id, Consumption.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.create_time, Consumption.last_update_time,
literal(None).label("complete_time"), Consumption.settled_time,
Consumption.sub_account_id, Consumption.scope_id, Consumption.store_scope_id,
).where(
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)
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:
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:
raise AccountError("INVALID_ARGUMENT")
if account_ids is not None:
......@@ -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
with accounts.factory.begin() as session:
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)
source_client = principal.client_id if principal.category == "CALL" else client_id
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))
offset = (page - 1) * size
# 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
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,
"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
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,
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,
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,
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
from .account_config import load_account_settings
from .account_model_gateway import ModelGateway
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_schemas import (
AccountQueryRequest, ActivateCredentialRequest, ClientId, CreateAccountRequest, GiftRequest, Identifier,
ChatRequest, IssueCredentialRequest, LedgerPageRequest, ReconcileCallRequest, ReconcileGiftRequest,
RevokeCredentialRequest, SetAccountStatusRequest, SetClientRequest,
AccountQueryRequest, ActivateCredentialRequest, BindScopeRequest, ClientId, CreateAccountRequest,
CreateSubAccountRequest, GiftRequest, Identifier, ChatRequest, IssueCredentialRequest,
LedgerPageRequest, MoveEmployeeRequest, MoveStoreRequest, ReconcileCallRequest,
ScopeMonthPageRequest,
ReconcileGiftRequest, RevokeCredentialRequest, SetAccountStatusRequest, SetClientRequest,
SetScopeQuotaRequest, SetScopeStatusRequest, SetSubAccountStatusRequest, TransferRequest,
)
from .account_openai import (
OpenAIChatRequest, OpenAIStreamResponse, completion_response, execution_error, models_response, openai_error,
......@@ -32,7 +35,7 @@ from .account_openai import (
from .account_security import AccountError, ERRORS, require_management, validate_request_id
from .account_service import AccountService
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 .id_generator import GENERATOR_KEY, ID_HIGH_EXCLUSIVE, ID_LOW, SegmentIDGenerator
......@@ -118,7 +121,8 @@ class AccountBoundary:
elif (path.startswith("/api/") or path.startswith("/v1/")) and principal.category != "CALL":
raise AccountError("PERMISSION_DENIED")
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 仍可幂等重放。
if (scope["method"] == "POST" and path not in read_posts and path != "/v1/chat/completions"
and request_id is None):
......@@ -210,7 +214,7 @@ def create_account_app(*, settings=None, service=None):
app.state.service = service
yield
return
engine = gateway = None
engine = generator_engine = gateway = None
try:
if settings is None:
raise RuntimeError("必须通过显式配置文件启动")
......@@ -219,19 +223,27 @@ def create_account_app(*, settings=None, service=None):
max_overflow=settings.db_max_overflow, bounded_operations=True)
await run_in_threadpool(_database_ready, engine)
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)
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:
if gateway is not None:
await gateway.aclose()
if engine is not None:
engine.dispose()
if generator_engine is not None:
generator_engine.dispose()
raise RuntimeError("独立算力服务初始化失败,请核查配置与数据库") from None
try:
yield
finally:
await gateway.aclose()
engine.dispose()
generator_engine.dispose()
app = FastAPI(title="mei1-computing-service", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
if service is not None:
......@@ -309,8 +321,11 @@ def create_account_app(*, settings=None, service=None):
@app.post("/internal/v1/accounts/{account_id}/credentials")
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),
int(account_id), body.client_id))
return reply(request, request.app.state.service.issue_credential(
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")
def activate(request: Request, credential_id: Identifier, body: ActivateCredentialRequest):
......@@ -326,8 +341,17 @@ def create_account_app(*, settings=None, service=None):
))
@app.get("/internal/v1/accounts/{account_id}/credentials")
def credentials(request: Request, account_id: Identifier, client_id: Optional[str] = Query(default=None, alias="clientId")):
return reply(request, request.app.state.service.list_credentials(request.state.secret, int(account_id), client_id))
def credentials(request: Request, account_id: Identifier,
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")
def clients(request: Request, account_id: Identifier, body: SetClientRequest):
......@@ -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),
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")
async def gift(request: Request, body: GiftRequest):
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):
)
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("/internal/v1/consumptions/page")
def consumptions_page(request: Request, body: LedgerPageRequest):
......
......@@ -2,6 +2,7 @@ from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import (
CHAR,
JSON,
BigInteger,
Boolean,
......@@ -120,6 +121,15 @@ _SETTLEMENT_STATUSES = (
"SETTLE_FAILED",
"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):
......@@ -218,8 +228,10 @@ class Credential(_Timestamps, Base):
__table_args__ = (
_account_client_fk("fk_computing_credential_account_client"),
CheckConstraint(
"(category = 'CALL' AND account_id IS NOT NULL AND issued_by IS NOT NULL) "
"OR (category IN ('INTEGRATION', 'PLATFORM') AND account_id IS NULL)",
"(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)",
name="ck_computing_credential_scope",
),
Index("idx_computing_credential_scope_status", "account_id", "client_id", "status"),
......@@ -234,6 +246,12 @@ class Credential(_Timestamps, Base):
_identifier(50), ForeignKey("t_computing_client.client_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_mask: Mapped[str] = mapped_column(_identifier(20))
status: Mapped[str] = mapped_column(
......@@ -271,12 +289,7 @@ class ManagementRequest(_Timestamps, Base):
_ID, ForeignKey("t_computing_credential.id")
)
operation_type: Mapped[str] = mapped_column(
_enum(
"computing_management_request_operation",
"PROVISION_ACCOUNT", "ISSUE_CALL_KEY", "ACTIVATE_CREDENTIAL",
"REVOKE_CREDENTIAL", "GRANT_CLIENT", "REVOKE_CLIENT",
"SET_ACCOUNT_STATUS", "RECONCILE",
)
_enum("computing_management_request_operation", *_MANAGEMENT_OPERATIONS)
)
request_id: Mapped[str] = mapped_column(_identifier(100))
fingerprint: Mapped[str] = mapped_column(_identifier(64))
......@@ -407,6 +420,10 @@ class Call(_Timestamps, Base):
name="ck_computing_call_lease",
),
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(
"idx_computing_call_retry", "billing_status", "next_retry_time", "lease_until"
),
......@@ -470,6 +487,14 @@ class Call(_Timestamps, Base):
_ID, ForeignKey("t_computing_credential.id")
)
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):
......@@ -501,6 +526,9 @@ class Consumption(_Timestamps, Base):
"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"),
# 与 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,
)
......@@ -531,6 +559,9 @@ class Consumption(_Timestamps, Base):
legacy_record: Mapped[bool] = mapped_column(
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):
......@@ -561,20 +592,30 @@ class PointRecord(_Timestamps, Base):
),
CheckConstraint(
"(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 "
"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",
),
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,
)
id: Mapped[int] = mapped_column(_ID, primary_key=True, autoincrement=False)
account_id: Mapped[int] = mapped_column(_ID)
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)
balance_before_units: Mapped[int] = mapped_column(BigInteger)
balance_after_units: Mapped[int] = mapped_column(BigInteger)
......@@ -582,6 +623,9 @@ class PointRecord(_Timestamps, Base):
gift_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)
sub_account_id: Mapped[Optional[int]] = mapped_column(
_ID, ForeignKey("t_computing_sub_account.id")
)
legacy_record: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("0")
)
......@@ -589,6 +633,127 @@ class PointRecord(_Timestamps, Base):
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):
__tablename__ = "t_computing_id_segment"
__table_args__ = (
......
......@@ -84,6 +84,8 @@ def openai_error(error: AccountError) -> JSONResponse:
error_type = "api_error"
elif error.code == "RATE_LIMITED":
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":
status, error_type = 429, "insufficient_quota"
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):
......@@ -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):
return session.scalar(
select(Call).where(
......
......@@ -50,6 +50,14 @@ class CreateAccountRequest(WriteRequest):
class IssueCredentialRequest(WriteRequest):
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):
......@@ -80,6 +88,66 @@ class SetAccountStatusRequest(WriteRequest):
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):
account_ids: Optional[List[Identifier]] = Field(default=None, min_length=1, max_length=1000)
page: int = Field(default=1, ge=1)
......@@ -123,12 +191,23 @@ class ReconcileGiftRequest(WriteRequest):
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):
owner_client_id: Optional[ClientId] = None
client_id: Optional[ClientId] = None
request_id: Optional[RequestId] = None
start: 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")
@classmethod
......
......@@ -5,6 +5,7 @@ import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from zoneinfo import ZoneInfo
from .account_models import Account, AccountClient, Client, Credential
from .errors import AppError
......@@ -18,25 +19,36 @@ ERRORS = {
"CREDENTIAL_REVOKED": (401, "凭证已撤销"),
"ACCOUNT_ACCESS_REVOKED": (403, "账户来源授权已撤销"),
"ACCOUNT_DISABLED": (403, "账户已停用"),
"SUB_ACCOUNT_DISABLED": (403, "子账号已停用"),
"SCOPE_DISABLED": (403, "门店或员工已停用"),
"PERMISSION_DENIED": (403, "无权执行此操作"),
"ACCOUNT_NOT_FOUND": (404, "账户不存在或不可见"),
"SUB_ACCOUNT_NOT_FOUND": (404, "子账号不存在或不可见"),
"REQUEST_NOT_FOUND": (404, "请求或凭证不存在或不可见"),
"FINGERPRINT_MISMATCH": (409, "相同请求号的参数与原请求不一致"),
"ACCOUNT_BLOCKED": (409, "当前状态不允许此操作"),
"SCOPE_CONFLICT": (409, "门店或员工标识已存在"),
"GIFT_GATE_BUSY": (409, "账户正被其他赠送占用"),
"RATE_LIMITED": (429, "请求数量超过处理容量"),
"EMPLOYEE_QUOTA_EXCEEDED": (429, "员工当月额度已用尽"),
"DEPENDENCY_UNAVAILABLE": (503, "依赖尚未就绪"),
"SERVICE_UNAVAILABLE": (503, "服务暂不可用,请查询原请求"),
}
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")
SCOPE_KEY_PATTERN = re.compile(r"[a-z0-9-]{1,30}:[A-Za-z0-9._:-]{1,69}\Z")
class AccountError(AppError):
def __init__(self, code, *, reason=None):
def __init__(self, code, *, reason=None, data=None):
status, message = ERRORS[code]
super().__init__(code, message)
self.http_status = status
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
......@@ -46,12 +58,20 @@ class Principal:
client_id: str
category: str
account_id: Optional[int]
sub_account_id: Optional[int] = None
scope_id: Optional[int] = None
def utc_now():
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):
secret = "ck-%s-%s" % (credential_id, secrets.token_urlsafe(32))
return secret, hashlib.sha256(secret.encode("ascii")).hexdigest(), secret[:4] + "****" + secret[-4:]
......@@ -63,6 +83,15 @@ def validate_request_id(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):
match = KEY_PATTERN.fullmatch(secret) if isinstance(secret, str) else None
if match is None:
......@@ -89,7 +118,8 @@ def authenticate(session, secret, *, now=None):
access = session.get(AccountClient, (credential.account_id, credential.client_id))
if account is None or access is None or access.status != "AUTHORIZED":
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):
......
import asyncio
import hashlib
import json
import logging
import time
from datetime import timedelta
from threading import BoundedSemaphore
......@@ -10,11 +11,20 @@ from sqlalchemy.exc import IntegrityError
from starlette.concurrency import run_in_threadpool
from . import account_repository as repository
from . import account_admission as admission
from .account_gateway import GatewayError
from .account_models import Account, AccountClient, Call, Client, Credential, ManagementRequest, OperationAudit
from .account_security import AccountError, authenticate, issue_secret, require_management, utc_now, validate_request_id
from .account_models import (
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
logger = logging.getLogger(__name__)
def fingerprint(values):
return hashlib.sha256(json.dumps(values, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
......@@ -24,11 +34,48 @@ def _time(value):
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 {
"credentialId": str(row.id), "clientId": row.client_id,
"accountId": str(row.account_id) if row.account_id is not None else None,
"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),
"activatedAt": _time(row.activated_at),
"replacedBy": str(row.replaced_by) if row.replaced_by is not None else None,
......@@ -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):
if account is None:
raise AccountError("ACCOUNT_NOT_FOUND")
......@@ -82,7 +137,24 @@ class AccountService:
account = session.get(Account, row.target_id)
data.update(accountId=str(account.id), provisionStatus=account.provision_status)
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"):
from .account_ledger import gift_view
from .account_models import Gift
......@@ -99,22 +171,22 @@ class AccountService:
).with_for_update())
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(
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,
request_id=request_id, reason=reason or operation,
from_status=from_status, to_status=to_status,
evidence_ref=evidence_ref,
))
def _mutate(self, secret, request_id, operation, parameters, action, *, account_id=None,
credential_id=None, platform_only=False):
validate_request_id(request_id)
require_management(self.identity(secret))
digest = fingerprint(parameters)
for attempt in range(2):
try:
with self.factory.begin() as session:
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":
......@@ -132,27 +204,50 @@ class AccountService:
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,
credential_id=None, platform_only=False, extra_ids=0, audit_evidence=None):
validate_request_id(request_id)
require_management(self.identity(secret))
digest = fingerprint(parameters)
for attempt in range(2):
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:
principal, account, target, account_id, row = self._guard(
session, secret, operation, request_id, digest, parameters, account_id,
credential_id, platform_only, lock=True)
if row is not None:
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(
id=ids[0], client_id=principal.client_id, actor_credential_id=principal.credential_id,
operation_type=operation, request_id=request_id, fingerprint=digest,
)
session.add(row)
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
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()
data = self._view(session, row)
if raw_secret is not None:
......@@ -163,8 +258,10 @@ class AccountService:
raise AccountError("SERVICE_UNAVAILABLE") from None
raise AccountError("SERVICE_UNAVAILABLE")
def issue_credential(self, secret, request_id, account_id, client_id=None):
def action(session, principal, account, target, id):
def issue_credential(self, secret, request_id, account_id, client_id=None,
sub_account_id=None, scope_id=None):
def action(session, principal, account, target, ids):
id = ids[0]
target_client = _client_scope(principal, client_id)
client = session.get(Client, target_client)
access = session.get(AccountClient, (account.id, target_client))
......@@ -174,18 +271,39 @@ class AccountService:
raise AccountError("ACCOUNT_ACCESS_REVOKED")
if account.status != "ACTIVE":
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)
session.add(Credential(
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",
pending_expires_at=utc_now() + timedelta(hours=24), issued_by=principal.credential_id,
))
return "CREDENTIAL", id, {"credentialId": str(id)}, raw
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 action(session, principal, account, target, id):
def action(session, principal, account, target, ids):
if target.category != "CALL":
raise AccountError("PERMISSION_DENIED")
if account.status != "ACTIVE":
......@@ -228,7 +346,7 @@ class AccountService:
action, credential_id=credential_id)
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.status, current.revoke_reason = "REVOKED", reason
return "CREDENTIAL", current.id, {"credentialId": str(current.id)}, None
......@@ -237,7 +355,7 @@ class AccountService:
action, credential_id=credential_id)
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)
if client is None or (status == "AUTHORIZED" and client.status != "ACTIVE"):
raise AccountError("PERMISSION_DENIED")
......@@ -253,7 +371,7 @@ class AccountService:
action, account_id=account_id, platform_only=True)
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
or repository.count_uncertain_calls(session, account.id)):
raise AccountError("ACCOUNT_BLOCKED", reason="UNRESOLVED_OPERATION")
......@@ -263,47 +381,346 @@ class AccountService:
{"accountId": str(account_id), "status": status, "reason": reason},
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:
principal = authenticate(session, secret)
require_management(principal)
account = session.get(Account, account_id)
_account_scope(session, principal, account)
target_client = _client_scope(principal, client_id)
rows = session.scalars(select(Credential).where(
Credential.account_id == account_id, Credential.client_id == target_client,
).order_by(Credential.create_time.desc(), Credential.id.desc()).limit(201)).all()
if len(rows) > 200:
raise AccountError("INVALID_ARGUMENT", reason="CREDENTIAL_LIST_LIMIT")
return {"list": [credential_view(row) for row in rows]}
def _account_view(self, session, account, *, uncertain=None):
conditions = [Credential.account_id == account_id, Credential.client_id == target_client]
if status is not None:
conditions.append(Credential.status == status)
if sub_account_id is not None:
conditions.append(Credential.sub_account_id == int(sub_account_id))
if scope_id is not None:
conditions.append(Credential.scope_id == int(scope_id))
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 = []
if account.status != "ACTIVE":
reasons.append("ACCOUNT_DISABLED")
if account.provision_status != "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:
uncertain = repository.count_uncertain_calls(session, account.id) > 0
if uncertain:
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,
"ownerClientId": account.owner_client_id, "status": account.status,
"provisionStatus": account.provision_status, "balancePointUnits": str(account.balance_point_units),
"balancePoints": format_points(account.balance_point_units), "available": not reasons, "blockedReasons": reasons,
"provisionStatus": account.provision_status, "balancePointUnits": str(balance),
"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):
with self.factory() as session:
principal = authenticate(session, secret)
if principal.category != "CALL":
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):
with self.factory() as session:
......@@ -333,7 +750,11 @@ class AccountService:
validate_request_id(request_id)
platform_operations = {"GRANT_CLIENT", "REVOKE_CLIENT", "SET_ACCOUNT_STATUS", "RECONCILE"}
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")
identity = self.identity(secret)
require_management(identity)
......@@ -354,6 +775,9 @@ class AccountService:
if target.category != "CALL":
raise AccountError("PERMISSION_DENIED")
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:
account_id = row.target_id if row.operation_type != "RECONCILE" else None
if account_id is not None:
......@@ -369,6 +793,21 @@ class AccountService:
digest = fingerprint({"name": name, "clientId": client_id})
for attempt in range(2):
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:
principal = authenticate(session, secret)
require_management(principal)
......@@ -382,7 +821,6 @@ class AccountService:
if row.fingerprint != digest:
raise AccountError("FINGERPRINT_MISMATCH")
return self._view(session, row)
ids = self._ids(3)
account = Account(id=ids[1], name=name, remark=remark, owner_client_id=owner,
gateway_token_name="computing-%s" % ids[1],
gateway_snapshot={"gatewayId": self.settings.gateway_identity,
......
......@@ -46,6 +46,29 @@ def _clear_lease(call):
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,
*, source="GATEWAY_LOG", now):
binding = _binding(call)
......@@ -71,7 +94,18 @@ def apply_settlement(session, account, call, evidence, consumption_id, point_id,
raise AccountError("ACCOUNT_BLOCKED", reason="INVALID_SETTLEMENT_EVIDENCE")
try:
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)
except (ValueError, OverflowError):
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,
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,
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.flush()
......@@ -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_per_quota=POINT_UNITS_PER_QUOTA, call_id=call.id,
consumption_record_id=consumption_id, request_id=call.request_id,
sub_account_id=call.sub_account_id,
))
account.balance_point_units = after
account.version += 1
holder.balance_point_units = after
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.consumed_quota = quota
call.point_units_per_quota = POINT_UNITS_PER_QUOTA
......@@ -215,10 +259,20 @@ class SettlementService:
call.error_code, call.error_message = code, None
if permanent or call.retry_count >= 24:
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:
call.billing_status = "SETTLE_PENDING"
minutes = _RETRY_MINUTES[min(max(call.retry_count - 1, 0), len(_RETRY_MINUTES) - 1)]
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
def _retain_negative(self, state, evidence):
......
......@@ -11,7 +11,7 @@ from .account_model_gateway import ModelGateway
from .account_models import IdSegment
from .account_service import AccountService
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
......@@ -22,12 +22,17 @@ async def run(settings, batch_size):
settings.validate()
engine = create_mysql_engine(settings.database_url, pool_size=settings.db_pool_size,
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
try:
await asyncio.to_thread(_database_ready, engine)
factory = sessionmaker(engine, expire_on_commit=False)
generator_factory = sessionmaker(generator_engine, expire_on_commit=False)
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)
settled = await settlements.run_once(batch_size)
cleared = await run_database(accounts, settlements.clear_results, batch_size)
......@@ -37,6 +42,7 @@ async def run(settings, batch_size):
if gateway is not None:
await gateway.aclose()
engine.dispose()
generator_engine.dispose()
def main():
......
......@@ -55,6 +55,17 @@ def create_mysql_engine(database_url: str, *, pool_size=5, max_overflow=0, bound
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:
"""按环境配置初始化全局 Engine。重复调用忽略(uvicorn 多次加载保护)。"""
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`(边界与迁移)
- 网关实测资料:`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 @@
| `/api/token/*` 全部 UserAuth,**无管理员代管令牌接口**;PAT 无法程序化生成/续期 | 用"每子账号一个 NewAPI 账号"= 每开一个子账号要人工交付一个 PAT,运营不可行 |
| 每用户令牌上限默认 1000;本服务按名全量分页上限 5000 | 门店/员工**不能**映射成令牌 |
| `PUT /api/token/` 全量覆盖、无乐观锁;`expired_time` 被清成 0 会静默 401 | **余额分配不能实现为改令牌额度** |
| 计费权威 = 使用日志 `quota`(响应体无费用字段);日志维度只有 token/user | 网关只能出「商户总账号」账;门店/员工账必须本地出 |
| 计费权威 = 使用日志 `quota`(响应体无费用字段);日志维度只有 token/user | 网关只能出「商户总账号」账;子账号/门店/员工账必须本地出 |
**结论**: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 @@
| `remark` | VARCHAR(500) NULL | 400/500 字符上限沿用现有约定 |
| `status` | ENUM('ACTIVE','DISABLED') | DISABLED 拒绝其下全部新调用 |
| `balance_point_units` | BIGINT NOT NULL | 有符号子单位;分配即冻结 |
| `transfer_gate` | BIGINT UNSIGNED NULL | 转账门闩(复用 account.gift_gate 的思路),占用中的 transferId |
| `version` | INT NOT NULL | 条件写围栏 |
| 索引 | `(account_id, create_time, id)`;`UNIQUE(account_id, name)` | |
**不绑网关令牌**(理由见 §1)。
### 2.2 新表 `t_computing_scope`(门店/员工限额主体)
### 3.2 新表 `t_computing_scope`(门店/员工主体)
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | BIGINT UNSIGNED PK | 18 位,即 `scopeId` |
| `account_id` | BIGINT UNSIGNED NOT NULL | 归属账户(冗余,用于复合外键与查询) |
| `sub_account_id` | BIGINT UNSIGNED NOT NULL FK sub_account | 门店挂载的子账号(员工继承其门店) |
| `account_id` | BIGINT UNSIGNED NOT NULL | 归属商户账户(冗余,供复合外键与授权校验) |
| `sub_account_id` | BIGINT UNSIGNED NULL FK sub_account | **仅门店行可非空**(CHECK:EMPLOYEE 必须为 NULL);NULL = **直挂账户**(无子账号时门店/员工共用总账号未分配余额,D10),非空 = 挂子账号;员工所属子账号一律经 parent 门店解析,不存冗余列(消除改派漂移面) |
| `scope_type` | ENUM('STORE','EMPLOYEE') | |
| `scope_key` | VARCHAR(100) ascii_bin | **SaaS 侧 opaque 标识**(如 `store:123`),本服务不解析、不查 SaaS |
| `parent_scope_id` | BIGINT UNSIGNED NULL FK scope | 员工→门店;门店为 NULL |
| `limit_point_units` | BIGINT NOT NULL DEFAULT 0 | **最高使用金额** |
| `extra_point_units` | BIGINT NOT NULL DEFAULT 0 | **额外增加金额**(追加,只增不减,可审计) |
| `used_point_units` | BIGINT NOT NULL DEFAULT 0 | 累计消耗(**软限额:由结算事务累加**) |
| `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。**员工换店 = 变更此列**(管理操作,幂等 + 审计 + version 围栏) |
| `status` | ENUM('ACTIVE','DISABLED') | 停用拒新调用,不影响在途结算 |
| `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`。
`used` **允许超过** `limit+extra`(软限额只拦新调用,不清账、不写成 0)。
**跨行约束走应用层**(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、绕过月额度。
### 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_call` | `sub_account_id`(快照)、`usage_scope_type`、`usage_scope_key`、`parent_scope_key`(快照) | 登记时固化归属,用于结算时的双档扣减;**进指纹** |
| `t_computing_consumption` | `sub_account_id`、`usage_scope_type`、`usage_scope_key` | 门店/员工分账的数据源 |
| `t_computing_management_request` | `operation_type` 增 `CREATE_SUB_ACCOUNT`、`TRANSFER`、`BIND_SCOPE`、`SET_SCOPE_LIMIT`、`TOPUP_SCOPE` | 各自幂等域 |
| `t_computing_point_record` | `type` 增 `TRANSFER_OUT`/`TRANSFER_IN` | ⚠️ 需修订 P0 §2.8(现只允许 GIFT/CONSUME,且明确"不提供任意余额 ADJUST");转账必须成对、同事务、守恒、可审计 |
| `t_computing_operation_audit` | 复用,`target_type` 增 `SUB_ACCOUNT`/`SCOPE`;`evidence_ref` 记录 limit/extra 前后值 | |
| `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_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_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_consumption` | `sub_account_id`、`scope_id`、`store_scope_id` | 子账号/门店/员工分账的数据源 |
| `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`/`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"完全同构;撤销/轮换能细到子账号 |
| 只靠请求参数传 `subAccountId` | 依赖 B 端自律 | 0 | ❌ 越权面大,不采纳 |
| 门店/员工级独立 Key | — | 数量大(上千)+ 人员流动 → 生命周期不可运维 | ❌ 不采纳,用参数 + 锚点校验 |
| GIFT | 账户桶 | + | 现状不变(仍单一入口:先入总账号) |
| CONSUME | 按扣费桶 | − | 账户级 Key 与**直挂门店/员工**的消费记账户桶(sub_account_id=NULL);挂子账号的门店/员工及子账号 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` | 计费与扣减 |
|---|---|---|
| 账户级 CALL Key(`sub_account_id=NULL`) | 省略 | 扣**账户未分配余额**(现存行为,向后兼容) |
| 账户级 CALL Key | `STORE:门店` | 校验门店∈账户 → 扣 门店.used + 门店所属子账号余额 |
| 账户级 CALL Key | `EMPLOYEE:员工` | 校验员工∈账户 → 扣 **员工.used + 员工门店.used** + 子账号余额 |
| 子账号 CALL Key | 省略 | 扣 子账号余额(不占门店/员工限额) |
| 子账号 CALL Key | `STORE/EMPLOYEE`(须属本子账号) | 双档扣减 + 扣本子账号余额 |
### 5.3 转账操作(D1 分配即冻结,维持 v0.1 结论)
**另存于 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)
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 加密保存
新增管理操作的幂等指纹写全(同号异参必须 409)。**扩展现有操作时字段名逐字沿用既有命名,不得重命名**——`ISSUE_CALL_KEY` 现为 camelCase `{accountId, clientId}`,gift 为 snake_case `{account_id, points, operator_note}`;下表新操作命名跟随所属接口族:
### 阶段 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
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)
## 7. 调用与结算链路
### 阶段 C 调用(每次请求)
### 7.1 准入顺序(`_admit` 扩展,锁序固定)
```
员工/门店发起
→ 自家 B 端后端:校验登录态与门店/员工归属(B 端主数据),解析 scope
→ 组装:messages + usageScope{type,key} + 稳定 X-Request-Id(禁止换号重试)
→ Authorization: Bearer <CALL Key>(子账号 Key 或账户级 Key)
→ mei1_computing 准入顺序:
① 凭证:KEY 格式 → 摘要恒定时间比较 → 状态/有效期 → client ACTIVE → account_client AUTHORIZED
② 幂等重放优先:命中 (accountId, clientId, requestId) 且指纹一致 → 返回既有状态(不做新准入)
③ scope 归属:必须属于凭证的 sub_account_id / 账户;员工父门店一致
④ 账户级:ACTIVE + provision READY + 无 gift_gate + 无 UNKNOWN/未核清 + 网关比例核对
⑤ 余额:子账号余额>0(子账号 Key)或账户未分配余额>0(账户级 Key)
⑥ 软限额:员工档 used < limit+extra 且 门店档 used < limit+extra(两档都过)
⑦ REGISTERED → DISPATCHING 条件写(唯一执行权 + dispatch_deadline)
→ 调 NewAPI(总账号令牌,一次、不重试)→ 保存正文 + X-Oneapi-Request-Id
→ 结算(即时或 worker 补偿):精确回捞 quota
→ 单事务:账户余额 −、子账号余额 −、scope(员工).used +、scope(门店).used +、
consumption(+scope) 、point_record(CONSUME) 同事务提交;call_id 唯一防重复结算
→ 返回:consumedPoints(账户/子账号/门店/员工各自视角可查)
① 凭证(格式→摘要恒定时间比较→状态/有效期→client ACTIVE→account_client AUTHORIZED)
② 幂等重放优先:命中 (account_id, client_id, request_id) 且指纹一致 → 返回既有状态。
指纹 = 现有请求体输入 + **凭证静态主体**(credential 的 scopeId/subAccountId,Key 绑定终身不变);
不含解析出的门店/子账号/月份——重放先于主体解析,换店、改派、跨月后的合法重放仍命中原记录
(P0 §5:命中即返回持久化状态、指纹不含可变主数据)
③ Key→主体解析:scope 状态 ACTIVE、parent 门店 ACTIVE、所属子账号 ACTIVE;
员工解析 (当前门店, 子账号, 生效月额度, 当月用量) —— **单条 SQL 一次读齐**,
落在既有准入短事务内;网关派发前事务已关闭(既有纪律:HTTP 阶段无未结束事务、网络调用前关闭 Session)
④ 账户级硬检查(不变):ACTIVE + provision READY + 无 gift_gate + 无 UNKNOWN/未核清 + 网关比例核对
⑤ 余额:对应桶 > 0(直挂门店/员工的桶 = 账户未分配余额,D10)
⑥ 员工月度限额(仅员工 Key):当月已用 < 生效额度;**仅当 (员工, 当前门店) 配置行不存在或值为 NULL(不限)时跳过**——该行有具体值则照常比对(§3.3,无回退链)
⑦ REGISTERED→DISPATCHING 条件写(唯一执行权 + dispatch_deadline);
派发前(claim)**重跑同一准入**(含 ⑥ 月额度与 scope/分桶状态):被拒则该次调用不派发(2026-09-30 决策:保持复查)。已派发调用在途照结,不受之后停用/调额影响。
快照 sub_account_id / scope_id / store_scope_id / quota_month 落 call 行(只作结算归属,不进指纹)
```
### 阶段 D 结算、补偿与对账
锁序:`account` → `sub_account` → `scope`(按 scope id 升序)→ `scope_month_usage`;准入与结算两端一致,避免死锁。
14. 未结算:`202 + retryHint=QUERY_ORIGINAL_REQUEST`;客户端以原 `requestId` 查 `GET /api/v1/calls/{requestId}`
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`
**读口径一致性(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`(不取锁)。因此:
---
- `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 结算事务(单事务原子提交)
| # | 场景 | 预期行为 |
|---|---|---|
| S01 | 开商户总账号 | 现有 P2 流程不变;账户数 = 商户数(不撞网关令牌上限) |
| S02 | 账户注入额度 | 仅 PLATFORM 赠送;充值入口见 §9 D3 |
| S03 | 创建子账号(重放) | 同 requestId 返回同一 subAccountId;不重复创建 |
| S04 | 总账号 → 子账号转账 | 冻结式扣减;超额 → 拒绝,无部分转账 |
| 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` 超限 |
1. call 行 version 围栏 + 终态推进(现状不变);
2. 扣费桶余额条件扣减(账户桶或子账号桶);
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 小时),报表标注数据截至最近结算;
4. `consumption` 带 `sub_account_id / scope_id / store_scope_id` 落库;
5. `point_record(CONSUME)` 落对应桶;
6. 审计。
### 5.2 调用与计费
| # | 场景 | 预期行为 |
|---|---|---|
| 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 | 删除子账号 | 有余额或有历史消费时**拒绝删除**(先转走/清零);建议只提供停用,不做物理删除 |
重复结算仍由 `call_id` 唯一 + version 围栏拒绝,不二次扣费(不变)。
### 5.3 运维与对账
### 7.3 场景矩阵
| # | 场景 | 预期行为 |
|---|---|---|
| S38 | 恒等式核对 | `未分配 + Σ子账号 = 账户总额`;`Σ流水 = Σ余额`;残差非零告警 |
| S39 | 门店/员工分账 | 本地 `consumption` 按 scope 分页;网关侧不参与 |
| S40 | 补偿重复结算 | `call_id` 唯一 + version 围栏;重复写回被拒,不二次扣费 |
| S41 | 转账结果不明 | 保持门闩 + 审计,只核对原请求;不换号重试、不重复搬运 |
| S42 | 已分配但长期未消耗 | 需要报表支持(子账号闲置额度);不自动回流 |
完整场景矩阵 **S01–S45 见附录 A**(沿用 v0.1 编号、行为按 v0.2 语义重写;v0.1 原文已被本文件覆盖,不另归档)。
---
## 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` 升序)→ 业务行;避免死锁。
2. **结算事务**:新登记的 scope 扣减与现有"consumption + point_record + 余额"在**同一事务**提交;`used` 累加必须条件写(`version`)。
3. **软限额语义**:准入只读比对;不做预留、不预扣(与 P0 §3.2 一致);超额是**已知且被接受**的窗口,用指标与告警兜底,不用"假装不会超"。
4. **指纹扩展**:`usageScope` 进调用指纹;子账号归属不重复进指纹(由凭证决定)。
5. **快照**:call 行保存登记时的 sub_account/scope 快照,结算只认快照。
6. **不做**:任意余额 ADJUST、跨子账号自动净额、门店/员工级凭证、子账号落网关令牌。
**不加**的两条与理由:① 子账号桶维度——桶通常持有账户绝大部分行,现有 `(account_id, create_time, id)` 顺扫即命中,
加索引只快 0.4 ms,不抵 ≈44 B/行的写放大;② `t_computing_scope(account_id, …)`——月账单页瓶颈在 `ORDER BY used`
排序而非 scope 扫描(6,300 行 scope 全扫仅 1.65 ms),scope 表比 consumption 小两个数量级;等「单账户 scope 到 10⁴
量级」或月账单明显变慢再评估。代价:每索引 ≈44 B/行(30 万行 ≈12.6 MB),一次结算在 call + consumption 上各多写 2 项。
- **当前对账脚本**:`scripts/sql/check.sql` 已合并基础与层级检查。B 部分保留层级检查编号:第 9 节核对调用/消费快照,第 11 节仅统计员工的登记月用量,第 13 节核对主体快照成对出现,第 14 节核对快照桶归属,第 15 节按账户与余额桶核对消费/流水。
- 当前账户余额仅与账户桶流水核对,子账号余额分别核对;门店本身不产生员工月用量。门店停用后其下员工保留是合法稳态,对账不要求父门店一直 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 错误码表 |
| 新增 DDL | `scripts/<日期>_account_hierarchy_and_scopes_ddl.sql`:2 表 + 扩列(沿用 `_ID`/`_identifier`/`_enum`/UTC DATETIME(3)/`_TABLE_OPTIONS` 约定,由 `app/account_models.py` 生成) |
| 代码触点 | `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`(新请求模型) |
| 测试 | 新增 `tests/test_account_sub_account.py`、`tests/test_account_scopes.py`;扩展 `test_account_calls.py`/`test_account_settlement.py` 的并发与超额用例;MySQL 专项分支同样补齐 |
| 未开工部分 | P5 Java / P6 前端 / P7 联调均未开始 ⇒ 现在加层级,返工成本最低(Java 侧只在未提交的 `ComputingServiceClient`/`Provision`/`Proxy` 里加字段) |
| 不受影响 | 网关侧运维形态不变(仍一商户一账号一令牌);现有 E2E 账户与账本无需迁移 |
| 需修订 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/sql/schema.sql`:包括全部 15 张表及层级字段、索引;由 `render_account_ddl.py` 生成。系统未上线,新环境执行全量基线;旧增量原文放入 `scripts/sql/archive/` 供追溯。 |
| 代码触点 | `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_scope.py`(分桶守恒、转账幂等回查、月度重置、跨月快照、换店累计、额度调整、四类 Key 矩阵、伪造/越权);扩展 `test_account_calls.py` / `test_account_settlement.py`;SQLite + MySQL 8.0.43 双全量回归(含既有 2100+ 用例不回归) |
| OpenAI 兼容层 | **协议零改动**:`/v1/chat/completions` 请求/响应体不变,主体由 Key 决定;仅 `openai_error` 增加一个 EMPLOYEE_QUOTA_EXCEEDED → `insufficient_quota` 映射分支 |
| 不受影响 | 网关侧一商户一账号一令牌;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 越权拒绝 |
| 2 | 门店/员工 scope + 归属 + 双档软限额扣减 | 一门店一账号、父子一致性、双档都校验都扣、伪造 scope 被拒 |
| 3 | scope 维度账本 + 对账恒等式 + 超额/闲置告警 | 分账分页、恒等式、超额窗口可观测 |
| 1 | 子账号 + 分桶账本 + 转账 + 子账号 Key | 幂等、守恒恒等式、无部分转账、桶隔离(子账号消费不碰未分配余额) |
| 2 | 门店/员工 scope + 四类 Key + 月度限额 | 一店一账号、员工月度重置/月中调整/跨月快照/换店累计、员工 Key 越权拒绝、停用路径(S32/S33:拒新调用 + 在途照常结算) |
| 3 ✅ | 分账报表 + 对账脚本扩展 + 超额窗口告警 + 分账索引 | 分账分页(含三维度过滤)、恒等式核对(脚本 12–15 节 + E2E)、S30/S31 可观测(结构化 warning)、索引实测结论(§8) |
---
## 11. 明确不做
任意余额 ADJUST(核清走既有事务)· 子账号落网关令牌 · 门店限额 · 子账号互转 · 跨子账号自动净额 · 请求参数传主体 · 跨商户 scope 迁移(换 key 重建,§3.2)· 历史月额度快照表(走 audit 重放,§3.3)。
## 9. 待确认的语义(阻塞实施)
## 12. 决策点(全部已确认,2026-09-29)
| # | 决策点 | 建议 |
| # | 决策点 | 结论 |
|---|---|---|
| D1 | 转账语义:**分配即冻结**(子账号只能花自己那份,总账号花未分配部分) vs 共享透支池(分配只是软标记) | 分配即冻结(字面符合"总账号把余额分配给子账号",且能防超分) |
| D2 | 转账权限与方向:PLATFORM only / owner INTEGRATION 也可;是否允许子账号互转 | 允许 owner INTEGRATION 做"总↔子"(不动总量);子账号互转本期不做 |
| D3 | "充值/购买积分"入口是否要做(现只有 PLATFORM 赠送) | 先继续用赠送;若要充值需新增支付对账语义,另立契约 |
| D4 | 门店改派子账号时 `used/limit/extra` 的处理 | 整体随门店搬迁;历史消费不动 |
| D5 | 子账号 CALL Key 是否采纳(本文 §3 建议采纳) | 采纳 |
| D6 | 新增错误码与 OpenAI 兼容映射(`SCOPE_LIMIT_EXCEEDED` 等) | 按 §5.2 S24 定义 |
| D7 | 子账号是否允许有自己的"赠送/充值"来源(平台直接给子账号发钱) | 不建议:一律先入总账号再分配,单一入口便于审计 |
| D1 | 转账语义:分配即冻结 | 维持 v0.1 建议(已按此设计) |
| D2 | 划拨权限:owner INTEGRATION 可做总↔子;子账号互转不做 | 维持 |
| D3 | 充值入口 | 先继续用 PLATFORM 赠送 |
| D4 | 门店改派时子账号桶余额处理 | 改派**不移动任何余额**(门店本身无余额);历史消费留在原桶;如需把额度迁到新子账号,走回流 + 再分配 |
| D8 | **员工额度配置粒度**:按 (员工×门店) 记忆配置(本文 §3.3)vs 单值存员工主体 | **已确认:按 (员工×门店) 记忆配置**——换店后新店配置优先,缺失即不限(无回退链,2026-09-30 复核),从未配置不限 |
| D9 | 每用户/每商户 Key 数量上限与监控(门店+员工 Key 规模) | **已确认:暂不做** Key 数量上限与监控;门店+员工 Key 规模可观,后续有实际需求再评估 |
| 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
- 状态:待实施的开发计划,不代表功能已完成或已授权上线
- 范围:`mei1-computing-service`、`mei1-saas`、`business-saas/mwcloud`、`opt`
......
# P0 契约冻结:独立算力账户服务
- 日期: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`(边界、迁移与切流)。
- 旧文档适用性:`design.md` 中商户/门店/员工、共享 SaaS 库、公网 RSA 部分;`authentication.md` 全部公网签名协议——均被本契约为核心的独立账户路线取代,仅作历史参考,见文末标注。
......@@ -15,13 +15,17 @@
| `requestId` | 调用方业务幂等号 | ASCII 可打印字符 8–100,禁止空白;`accountId + clientId + requestId` 唯一定位一次调用;gift 与 call 命名空间独立 |
| `callId` | 服务端调用记录 | 18 位数字字符串;兼任网关日志关联的内部 dispatchId 候选 |
| `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,仅备注追踪,不参与鉴权与归属 |
写操作必须带 `X-Request-Id` 头。`X-App-Id` 不再作为身份输入;如出现仅记录不采信。
## 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
......@@ -64,6 +68,8 @@
| issued_by | BIGINT UNSIGNED NULL | 签发凭证 id;仅受控 bootstrap 管理 Key 可空,CALL Key 必填 |
| 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 连重放也不可用。
### 2.5 management_request
......@@ -72,7 +78,7 @@
| --- | --- |
| id BIGINT UNSIGNED PK | |
| 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 | 调用方业务幂等号 |
| fingerprint CHAR(64) NOT NULL | 规范化参数摘要,同号异参 409 |
| target_id BIGINT UNSIGNED NULL | 目标 account/credential/gift |
......@@ -80,7 +86,7 @@
| result_ref JSON NULL | 结果引用(account_id、credential_id 等),不存 secret |
| 唯一 | `(client_id, operation_type, request_id)` |
开户、签发、激活、撤销、来源授权、账户状态与核清走本表登记;轮换使用激活操作携带 replaced credential。赠送仅使用 gift 表独立三元幂等域,避免同一管理来源在不同账户同号时被误拦。已有 accountId 的操作另带目标校验。
开户、签发、激活、撤销、来源授权、账户状态与核清走本表登记;层级管理(子账号/转账/门店/员工/额度,§2.10 与 §7.2)同样走本表,**建档与停启用拆分为不同 operation_type**(同号先建后停不得共用指纹)。轮换使用激活操作携带 replaced credential。赠送仅使用 gift 表独立三元幂等域,避免同一管理来源在不同账户同号时被误拦。已有 accountId 的操作另带目标校验。
### 2.6 gift
......@@ -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 | 补偿租约 |
| 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`,正文过期不改变账务终态。
### 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。
- 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。
- 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)
......@@ -160,6 +179,8 @@ P1 迚ゥ逅ィ。蝙句崋螳壻クコ 11 蠑 `t_computing_*` 陦ィ幃鬚/usage 譏ッ譛臥ャヲ蜿キ
非法转换一律拒绝并告警:不允许 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
REGISTERED → GATEWAY_WRITING → CONFIRMED(记账 SUCCESS);
......@@ -186,26 +207,36 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
| CREDENTIAL_REVOKED | 401 | 已撤销 |
| ACCOUNT_ACCESS_REVOKED | 403 | client 授权被撤 |
| 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 | |
| REQUEST_NOT_FOUND | 404 | 查询的原请求不存在 |
| 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 | 门闩被其他赠送占用 |
| RATE_LIMITED | 429 | 并发/容量上限 |
| EMPLOYEE_QUOTA_EXCEEDED | 429 | 员工月度额度不足;data 带 scopeId/quotaMonth/limitPointUnits/usedPointUnits(子单位十进制字符串) |
| DEPENDENCY_UNAVAILABLE | 503 | 配置/DB/网关未就绪,未登记新请求 |
| 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. 幂等与指纹
- 调用幂等域:`(account_id, client_id, request_id)`;gift 独立同构;管理操作 `(client_id, operation_type, request_id)`。
- 重放优先级:有效凭证校验通过后,先查原记录并比对指纹;命中即返回持久化状态(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}`(账户由凭证域固定时仍显式参与)。
- 层级管理操作指纹输入(扩展现有操作时字段名逐字沿用既有命名——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。
- 精度:所有 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. 固定权限矩阵
......@@ -216,6 +247,8 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
| 跨来源账户账务摘要 | 禁止 | 允许(限自有账户) | 允许 |
| 幂等开户 | 禁止 | 允许(owner=本 client) | 允许 |
| 签发/激活/撤销本来源调用 Key | 禁止 | 允许(限自有账户) | 允许 |
| 创建子账号、建/停门店与员工、员工换店、门店改派、设员工月额度 | 禁止 | 允许(限自有账户;subAccountId/scopeId 必属本账户) | 允许(显式目标账户) |
| 余额划拨(总↔子转账) | 禁止 | 允许(限自有账户,不动总量) | 允许 |
| 授权/撤销 client 接入、账户停启用 | 禁止 | 禁止 | 允许 |
| 赠送、人工核清 | 禁止 | 禁止 | 允许(不得任意修改余额) |
| 读取其他 client 模型正文 | 禁止 | 禁止 | 禁止(仅运维只读归档) |
......@@ -272,8 +305,18 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
- `GET /api/v1/account`:`balancePointUnits`(字符串,可负)、`available`(bool,>0 且 ACTIVE 且无阻断)。
- `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)`。
- 层级管理路由(§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. 旧调用方清单与迁移映射
......@@ -325,7 +368,9 @@ envelope啻{success, code, message, data, requestId}`縲ょキイ逋サ隶ー菴悴螳梧
- 已派发的核清必须超过持久化派发窗口,并附带能定位原操作终局的外部证据,确认原服务 worker 与网关请求均已排空/停止、旧写绝无迟到可能;暂停新流量或超时本身不等于排空。实时只读还须匹配原 Token/usedQuota/配置,以及确认到账时的目标额度或确认未到账时的原额度。任一不符则拒绝。服务不能校验证据编号背后的事实,操作责任人必须先审核;不能把一次旧值/目标值读数当终局证据。
- 核清重新锁 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,拒绝传入身份覆盖筛选。
- **分账维度(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 在此为筛选字段而非操作号。
- **月账单直读(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/状态/字段、单写隔离和人工证据流程仍属于上线验收门槛。
## 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]))
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.
-- 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.
......
# 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)一对一绑定,
-- 保存开户恢复请求号与 CALL Key AES-GCM 密文(base64,密钥来自部署 Secret,不入库明文)。
-- 2) 美际分析表补充 computing_request_id:稳定业务请求号 meiji-skin-analysis:{analysisId},
-- 用于按原请求号回查 /api/v1/calls/{request_id},不换号重发。
-- 注意:阶段 0 脚本 20260922_computing_stage0_ddl.sql 已含同一列增量;若该脚本已在目标库执行,
-- 本段 ALTER 会因列重复失败,跳过即可(仅执行第 1 段建表)。
-- 注意:../archive/20260922_computing_stage0_ddl.sql 已含同一列增量。
-- 执行前检查目标列和索引,选择尚未执行的语句;不要忽略重复列错误或使用 --force。
-- 遵循算力迁移 ID 规则:业务数值 ID 为 18 位、显式插入、无自增;本表主键沿用 SaaS 既有发号器生成的 BIGINT。
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):
m.account_id = open_account(m)
m.gateway = CallGateway(m)
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)
m.secret = m.credential["secret"]
m.calls = CallService(m.service)
......@@ -546,7 +548,7 @@ def test_rotation_and_changed_default_replay_original_snapshot_before_admission(
assert original.status_code == 200, original.text
data = original.json()["data"]
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"])
m.service.settings = replace(m.service.settings, newapi_model="replacement-model")
with m.factory.begin() as session:
......@@ -608,7 +610,7 @@ def test_results_and_request_identity_are_account_and_client_scoped(funded, scop
assert original.status_code == 200, original.text
if scope == "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")
assert gift(m, request_id="other-p4-gift", accountId=other_account).status_code == 200
else:
......
......@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
from app import account_repository as repository
from app.account_models import (
Account, AccountClient, Base, Call, Client, Consumption, Credential, Gift,
IdSegment, ManagementRequest, PointRecord,
IdSegment, ManagementRequest, PointRecord, SubAccount,
)
from app.db import create_mysql_engine
from app.id_generator import GENERATOR_KEY, ID_LOW, SEGMENT_SIZE, SegmentIDGenerator, seed_segment
......@@ -48,10 +48,12 @@ def account_engine(request):
"mysql+pymysql://root@localhost/" + database + "?unix_socket=" + str(socket_path)
)
try:
script = Path(__file__).resolve().parents[1] / "scripts/20260923_independent_account_ddl.sql"
# Fresh installations and MySQL tests use the same current full schema.
scripts = [Path(__file__).resolve().parents[1] / "scripts/sql/schema.sql"]
with engine.begin() as connection:
for script in scripts:
ddl = "\n".join(line for line in script.read_text(encoding="utf-8").splitlines()
if not line.lstrip().startswith("--"))
with engine.begin() as connection:
for statement in ddl.split(";"):
if statement.strip():
connection.execute(text(statement))
......@@ -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):
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)
for table in Base.metadata.tables.values():
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):
def test_generated_ddl_matches_models():
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 "AUTO_INCREMENT" not in render_ddl()
assert "DEFAULT (UTC_TIMESTAMP(3))" in render_ddl()
......@@ -484,14 +486,14 @@ def test_mysql_fork_while_generator_locked(account_engine):
("failed-call-success-consumption", {"consumption_call_mismatch"}),
("wrong-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):
if account_engine.dialect.name != "mysql":
pytest.skip("目标对账 SQL 使用 MySQL NULL-safe 比较")
s = account_session
c = call(101)
s.add_all([c, call(102, request_id="other-call", billing_status="FAILED")])
c = call(101, quota_month="2026-09")
s.add_all([c, call(102, request_id="other-call", billing_status="FAILED", quota_month="2026-09")])
s.flush()
legacy = case in {"valid-legacy", "success-call-legacy-consumption"}
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_
s.get(Account, A).balance_point_units = -146
seed_segment(s, ID_LOW + 10000, segment_model=IdSegment)
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()
if not line.lstrip().startswith("--"))
actual = set()
for statement in sql.split(";"):
if statement.strip():
result = s.execute(text(statement))
result = session.execute(text(statement))
if "anomaly" in result.keys():
actual.update(row.anomaly for row in result)
else:
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):
......
......@@ -162,7 +162,7 @@ def test_gift_role_target_and_revocation_precedence(gifting):
m = gifting
assert post(m, "/internal/v1/gifts", body(m)).status_code == 403
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)
assert post(m, "/internal/v1/gifts", body(m), key=key["secret"]).status_code == 403
data = gift(m).json()["data"]
......@@ -466,6 +466,36 @@ def test_mysql_concurrent_reconcile_is_single_credit(gifting, monkeypatch):
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):
import threading
m = 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"},
{"page": 0}, {"size": 201}, {"accountIds": [m.account_id] * 1001}]:
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)
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
......@@ -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):
m = ledger.m
credential = issue(m, str(ledger.a))
credential = issue(m, str(ledger.a), source="platform", client_id="client-a")
activate(m, credential)
secret = credential["secret"]
result = page_consumptions(m.service, secret)
......@@ -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"]
parameters = {"account_ids": [str(ledger.a)]} if role == "PLATFORM" else {}
if role == "CALL":
credential = issue(m, str(ledger.a))
credential = issue(m, str(ledger.a), source="platform", client_id="client-a")
activate(m, credential)
secret = credential["secret"]
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):
m = management
account_id = open_account(m)
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"]
denied = m.client.get("/api/v1/account", headers=headers(m, key=created["secret"]))
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
activate(m, created)
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):
response = m.client.post("/internal/v1/accounts/%s/status" % account_id,
json={"status": "DISABLED", "reason": "test"}, headers=forged)
assert response.status_code == 403
created = issue(m, account_id)
created = issue(m, account_id, source="platform", client_id="client-a")
activate(m, created)
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
......@@ -179,15 +179,20 @@ def test_roles_scope_and_forged_identity(management):
def test_revocation_precedes_replay_and_new_issuance(management):
m = management
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)
revoked = post(m, "/internal/v1/accounts/%s/clients" % account_id,
{"clientId": "client-a", "status": "REVOKED", "reason": "revoke access"}, source="platform")
assert revoked.status_code == 200
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 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):
......@@ -207,9 +212,9 @@ def test_platform_explicit_target_and_grant(management):
def test_rotation_grace_does_not_extend_and_revoke_is_immediate(management):
m = management
account_id = open_account(m)
old = issue(m, account_id)
old = issue(m, account_id, source="platform", client_id="client-a")
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"])
with m.factory() as s:
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):
def test_expiry_boundaries_and_disabled_client(management):
m = management
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:
s.get(Credential, int(created["credentialId"])).pending_expires_at = utc_now() - timedelta(seconds=1)
response = post(m, "/internal/v1/credentials/%s/activate" % created["credentialId"], {})
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)
with m.factory.begin() as s:
s.get(Credential, int(newer["credentialId"])).valid_until = utc_now() - timedelta(seconds=1)
......@@ -336,9 +341,10 @@ def segment_outage(monkeypatch, generator):
def test_credential_replay_survives_segment_failure(management, monkeypatch):
m = management
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)
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.json()["data"]["credentialId"] == created["credentialId"]
assert "secret" not in replay.json()["data"]
......@@ -369,15 +375,19 @@ def test_cancelled_provision_preserves_dispatch_evidence(management):
def test_disabled_account_allows_reads_and_does_not_reset_provision(management):
m = management
account_id = open_account(m)
created = issue(m, account_id)
activate(m, created)
sub_id = post(m, "/internal/v1/accounts/%s/sub-accounts" % account_id, {"name": "budget"},
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,
{"status": "DISABLED", "reason": "maintenance"}, source="platform")
assert response.status_code == 200
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 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):
......@@ -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):
m = management
account_id = open_account(m)
old = issue(m, account_id)
old = issue(m, account_id, source="platform", client_id="client-a")
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,
{"status": "DISABLED", "reason": "maintenance"}, source="platform").status_code == 200
response = post(m, "/internal/v1/credentials/%s/activate" % new["credentialId"],
......@@ -611,7 +621,7 @@ def test_mysql_concurrent_provision_and_issuance_are_once(management):
account_id = int(outcomes[0]["accountId"])
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:
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