feat(server): 工程骨架后端——上传 → 解析 → 清单最小链路

- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、
  problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点
- identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话
- billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局
- document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号
- checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐
- ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水
- 迁移:data-model 基线、套餐、Procrastinate、会话状态函数
- 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性
- Compose(含 migrate)、镜像、CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent 5747a523bb
commit ad7fba6021
106 changed files with 8094 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from sqlalchemy import select
from bid_doctor.billing import service
from bid_doctor.billing.models import Plan
from bid_doctor.core.db import anonymous_tx, tenant_tx
from bid_doctor.identity.deps import Ctx
router = APIRouter(tags=["billing"])
@router.get("/plans")
async def list_plans() -> dict[str, Any]:
async with anonymous_tx() as s:
plans = (await s.execute(select(Plan).where(Plan.active).order_by(Plan.code))).scalars().all()
return {
"items": [
{"code": p.code, "version": p.version, "name": p.name, "quotas": p.quotas, "limits": p.limits}
for p in plans
]
}
@router.get("/quota")
async def get_quota(ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.overview(s, ctx)

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from sqlalchemy import FetchedValue
from sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, DbTs, Json, Ts, UuidCol
from bid_doctor.core.db import Base
class Plan(Base):
__tablename__ = "plan"
code: Mapped[str] = mapped_column(primary_key=True)
version: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
quotas: Mapped[Json]
limits: Mapped[Json]
active: Mapped[bool] = mapped_column(server_default=FetchedValue())
class Subscription(Base):
__tablename__ = "subscription"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
plan_code: Mapped[str]
plan_version: Mapped[int]
status: Mapped[str] = mapped_column(server_default=FetchedValue())
source: Mapped[str]
seats: Mapped[int] = mapped_column(server_default=FetchedValue())
starts_at: Mapped[Ts]
ends_at: Mapped[Ts | None]
class QuotaAccount(Base):
__tablename__ = "quota_account"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
unit: Mapped[str]
balance: Mapped[int] = mapped_column(server_default=FetchedValue())
updated_at: Mapped[DbTs]
class QuotaEntry(Base):
"""只追加。amount 是对余额的影响(data-model §4.2)。"""
__tablename__ = "quota_entry"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
account_id: Mapped[UuidCol]
subscription_id: Mapped[UuidCol | None]
kind: Mapped[str]
amount: Mapped[int]
idempotency_key: Mapped[str]
hold_entry_id: Mapped[UuidCol | None]
ref_type: Mapped[str | None]
ref_id: Mapped[UuidCol | None]
project_id: Mapped[UuidCol | None]
reason: Mapped[str | None]
actor_user_id: Mapped[UuidCol | None]
created_at: Mapped[DbTs]

View File

@@ -0,0 +1,191 @@
"""额度(架构 §3.9、data-model §4.2)。流水只追加;余额缓存和流水在同一个事务里更新。
任务的三种结局(成功结算、失败退回、取消退回)都调用这里;同一笔预扣只能有一个结局(数据库唯一索引保证)。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.billing.models import Plan, QuotaAccount, QuotaEntry, Subscription
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
async def start_free_subscription(session: AsyncSession, ctx: TenantContext) -> None:
"""注册时:免费版订阅 + 额度账户 + 发放。"""
plan = (
await session.execute(
select(Plan).where(Plan.code == "free", Plan.active).order_by(Plan.version.desc()).limit(1)
)
).scalar_one()
sub = Subscription(
id=uuid7(),
tenant_id=ctx.tenant_id,
plan_code=plan.code,
plan_version=plan.version,
source="signup",
starts_at=datetime.now(UTC),
ends_at=None,
)
session.add(sub)
await session.flush()
for unit, amount in plan.quotas.items():
acct = QuotaAccount(id=uuid7(), tenant_id=ctx.tenant_id, unit=unit, balance=0)
session.add(acct)
await session.flush()
await _append(
session,
ctx,
acct,
kind="grant",
amount=amount,
key=f"grant:{sub.id}:{unit}",
subscription_id=sub.id,
reason="注册赠送",
)
async def current_plan(session: AsyncSession, ctx: TenantContext) -> tuple[Subscription, Plan]:
row = (
await session.execute(
select(Subscription, Plan)
.join(Plan, (Plan.code == Subscription.plan_code) & (Plan.version == Subscription.plan_version))
.where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "active")
)
).one()
return row[0], row[1]
async def hold(
session: AsyncSession, ctx: TenantContext, *, unit: str, ref_type: str, ref_id: UUID, project_id: UUID
) -> UUID:
"""预扣 1 次。先锁账户行,同一企业的并发预扣因此串行,不会扣成负数。"""
acct = (
await session.execute(
select(QuotaAccount)
.where(QuotaAccount.tenant_id == ctx.tenant_id, QuotaAccount.unit == unit)
.with_for_update()
)
).scalar_one_or_none()
if acct is None or acct.balance < 1:
raise AppError("quota_exhausted", "本期额度已用完。专业版续费或加量请联系客服。")
entry = await _append(
session,
ctx,
acct,
kind="hold",
amount=-1,
key=f"{ref_type}:{ref_id}:hold",
ref_type=ref_type,
ref_id=ref_id,
project_id=project_id,
)
return entry.id
async def settle(session: AsyncSession, ctx: TenantContext, hold_id: UUID) -> None:
await _finish(session, ctx, hold_id, "settle", 0)
async def release(session: AsyncSession, ctx: TenantContext, hold_id: UUID) -> None:
await _finish(session, ctx, hold_id, "release", 1)
async def _finish(session: AsyncSession, ctx: TenantContext, hold_id: UUID, kind: str, amount: int) -> None:
h = (await session.execute(select(QuotaEntry).where(QuotaEntry.id == hold_id))).scalar_one()
acct = (
await session.execute(select(QuotaAccount).where(QuotaAccount.id == h.account_id).with_for_update())
).scalar_one()
done = (
await session.execute(
text(
"SELECT 1 FROM quota_entry WHERE tenant_id = :t AND hold_entry_id = :h"
" AND kind IN ('settle', 'release')"
),
{"t": ctx.tenant_id, "h": hold_id},
)
).first()
if done: # 重复执行(worker 重试)时什么都不做
return
await _append(
session,
ctx,
acct,
kind=kind,
amount=amount,
key=f"{h.ref_type}:{h.ref_id}:{kind}",
hold_entry_id=hold_id,
ref_type=h.ref_type,
ref_id=h.ref_id,
project_id=h.project_id,
)
async def _append(
session: AsyncSession, ctx: TenantContext, acct: QuotaAccount, *, kind: str, amount: int, key: str, **fields: Any
) -> QuotaEntry:
entry = QuotaEntry(
id=uuid7(),
tenant_id=ctx.tenant_id,
account_id=acct.id,
kind=kind,
amount=amount,
idempotency_key=key,
actor_user_id=ctx.user_id,
**fields,
)
session.add(entry)
acct.balance += amount
await session.flush()
return entry
async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
sub, plan = await current_plan(session, ctx)
# 每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 预扣数 − 退回数 − 结算数。
# TODO(专业版续费):按订阅周期切分「本期」;MVP 的免费版只有一个周期。
rows = (
await session.execute(
text(
"SELECT a.unit, a.balance,"
" coalesce(sum(e.amount) FILTER (WHERE e.kind = 'grant' AND e.subscription_id = :s), 0),"
" count(e.id) FILTER (WHERE e.kind = 'hold'),"
" count(e.id) FILTER (WHERE e.kind = 'release'),"
" count(e.id) FILTER (WHERE e.kind = 'settle')"
" FROM quota_account a LEFT JOIN quota_entry e ON e.account_id = a.id"
" WHERE a.tenant_id = :t GROUP BY a.unit, a.balance ORDER BY a.unit"
),
{"t": ctx.tenant_id, "s": sub.id},
)
).all()
accounts: list[dict[str, Any]] = []
for unit, balance, granted, holds, releases, settles in rows:
used = holds - releases
accounts.append(
{
"unit": unit,
"balance": balance,
"held": used - settles,
"period_granted": granted,
"period_used": used,
"used_ratio": round(used / granted, 3) if granted else 0.0,
}
)
return {"plan": plan_summary(sub, plan), "accounts": accounts}
def plan_summary(sub: Subscription, plan: Plan) -> dict[str, Any]:
return {
"code": plan.code,
"name": plan.name,
"starts_at": sub.starts_at,
"ends_at": sub.ends_at,
"read_only": False,
}