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:
0
server/src/bid_doctor/billing/__init__.py
Normal file
0
server/src/bid_doctor/billing/__init__.py
Normal file
31
server/src/bid_doctor/billing/api.py
Normal file
31
server/src/bid_doctor/billing/api.py
Normal 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)
|
||||
59
server/src/bid_doctor/billing/models.py
Normal file
59
server/src/bid_doctor/billing/models.py
Normal 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]
|
||||
191
server/src/bid_doctor/billing/service.py
Normal file
191
server/src/bid_doctor/billing/service.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user