feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 15:06:06 +08:00
parent 54cbb556d0
commit dd8c04001d
78 changed files with 5064 additions and 104 deletions

View File

@@ -0,0 +1,66 @@
"""套餐开通、续费和到期(m6-plan §1.2、§1.3,决策 M6-5):
- subscription 加「scheduled」:续费到账时当前一期还没到期,新一期排队,到旧一期结束时由定时任务生效并发放额度;
- activation_request 加 request_no(界面和转账备注用的申请单号,如 KT-7Q3M2X);
- 通知加 subscription_expired(到期后只读)、activation_rejected(申请被退回);
- billing_due(now):定时任务找到期、该生效、该提醒的订阅。worker 受行级安全策略约束,看不到别的企业,
这里照登录前查询的做法(data-model §2.3)用 bd_auth 所有的 SECURITY DEFINER 函数,只返回编号。
Revision ID: 0010
"""
from __future__ import annotations
from alembic import op
revision = "0010"
down_revision = "0009"
def upgrade() -> None:
op.get_bind().exec_driver_sql("""
ALTER TABLE subscription DROP CONSTRAINT subscription_status_check;
ALTER TABLE subscription ADD CONSTRAINT subscription_status_check
CHECK (status IN ('scheduled', 'active', 'expired', 'cancelled'));
CREATE INDEX ON subscription (tenant_id, starts_at) WHERE status = 'scheduled';
ALTER TABLE activation_request ADD COLUMN request_no text UNIQUE;
ALTER TABLE notification DROP CONSTRAINT notification_kind_check;
ALTER TABLE notification ADD CONSTRAINT notification_kind_check
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed', 'report_succeeded',
'report_failed', 'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring',
'subscription_expired', 'activation_rejected'));
CREATE FUNCTION billing_due(p_now timestamptz)
RETURNS TABLE (tenant_id uuid, subscription_id uuid, action text)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$
SELECT s.tenant_id, s.id, 'expire' FROM subscription s
WHERE s.status = 'active' AND s.ends_at IS NOT NULL AND s.ends_at <= p_now
UNION ALL
SELECT s.tenant_id, s.id, 'start' FROM subscription s
WHERE s.status = 'scheduled' AND s.starts_at <= p_now
UNION ALL
SELECT s.tenant_id, s.id, 'remind' FROM subscription s
WHERE s.status = 'active' AND s.ends_at IS NOT NULL AND s.ends_at > p_now AND s.ends_at <= p_now + interval '30 days'
$$;
GRANT SELECT ON subscription TO bd_auth;
ALTER FUNCTION billing_due(timestamptz) OWNER TO bd_auth;
GRANT EXECUTE ON FUNCTION billing_due(timestamptz) TO bd_app;
""")
def downgrade() -> None:
op.get_bind().exec_driver_sql("""
DROP FUNCTION billing_due(timestamptz);
REVOKE SELECT ON subscription FROM bd_auth;
ALTER TABLE notification DROP CONSTRAINT notification_kind_check;
ALTER TABLE notification ADD CONSTRAINT notification_kind_check
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed', 'report_succeeded',
'report_failed', 'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring'));
ALTER TABLE activation_request DROP COLUMN request_no;
DROP INDEX IF EXISTS subscription_tenant_id_starts_at_idx;
ALTER TABLE subscription DROP CONSTRAINT subscription_status_check;
ALTER TABLE subscription ADD CONSTRAINT subscription_status_check
CHECK (status IN ('active', 'expired', 'cancelled'));
""")

View File

@@ -0,0 +1,37 @@
"""运营后台的数据库权限(决策 M6-2):
- bd_ops(部署脚本建,LOGIN BYPASSRLS):跨企业只读全部业务表;运营账号和会话表可写;能写审计(查看企业详情也留痕)。
对某个企业做的修改(开通、退回、开票、调额度)仍走 bd_app + 租户事务,行级安全策略照样生效;
- 运营账号和会话表只给 bd_ops:用户端的 api / worker(bd_app)读不到。
Revision ID: 0011
"""
from __future__ import annotations
from alembic import op
revision = "0011"
down_revision = "0010"
def upgrade() -> None:
op.get_bind().exec_driver_sql("""
GRANT USAGE ON SCHEMA app TO bd_ops;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO bd_ops;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO bd_ops;
GRANT INSERT, UPDATE ON ops_admin, ops_session TO bd_ops;
GRANT INSERT ON audit_event TO bd_ops;
REVOKE ALL ON ops_admin, ops_session FROM bd_app;
""")
def downgrade() -> None:
op.get_bind().exec_driver_sql("""
GRANT SELECT, INSERT, UPDATE, DELETE ON ops_admin, ops_session TO bd_app;
REVOKE INSERT ON audit_event FROM bd_ops;
REVOKE INSERT, UPDATE ON ops_admin, ops_session FROM bd_ops;
ALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE SELECT ON TABLES FROM bd_ops;
REVOKE SELECT ON ALL TABLES IN SCHEMA app FROM bd_ops;
REVOKE USAGE ON SCHEMA app FROM bd_ops;
""")

View File

@@ -0,0 +1,22 @@
"""运营人员的角色(只留结构):以后会有管理员、运营、销售、运维等不同权限的人。
这里只加 ops_admin.role,不加 CHECK:角色和权限点的对应关系在代码里(ops/auth.py 的 ROLE_PERMISSIONS),
加角色时只改那里;接口都已经过权限检查点,菜单按权限点过滤。现在只有「管理员」一种,拥有全部权限。
Revision ID: 0012
"""
from __future__ import annotations
from alembic import op
revision = "0012"
down_revision = "0011"
def upgrade() -> None:
op.get_bind().exec_driver_sql("ALTER TABLE ops_admin ADD COLUMN role text NOT NULL DEFAULT 'admin'")
def downgrade() -> None:
op.get_bind().exec_driver_sql("ALTER TABLE ops_admin DROP COLUMN role")

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from sqlalchemy.orm import Mapped
from typing import Any
from bid_doctor.core.columns import PK, Bytes, DbTs, Json, UuidCol
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, Bytes, DbTs, UuidCol
from bid_doctor.core.db import Base
@@ -12,13 +15,15 @@ class AuditEvent(Base):
tenant_id: Mapped[UuidCol]
actor_kind: Mapped[str]
actor_user_id: Mapped[UuidCol | None]
actor_ops_id: Mapped[UuidCol | None] = mapped_column(default=None)
actor_name: Mapped[str | None]
action: Mapped[str]
object_type: Mapped[str]
object_id: Mapped[UuidCol | None]
project_id: Mapped[UuidCol | None]
before: Mapped[Json | None]
after: Mapped[Json | None]
# None 存成 SQL NULL(不是 JSON 的 null):抹掉内容的触发器和「还有没有内容」的判断都按 IS NULL
before: Mapped[Any | None] = mapped_column(JSONB(none_as_null=True))
after: Mapped[Any | None] = mapped_column(JSONB(none_as_null=True))
content_digest: Mapped[Bytes | None]
request_id: Mapped[str | None]
created_at: Mapped[DbTs]

View File

@@ -26,6 +26,7 @@ async def record(
before: dict[str, Any] | None = None,
after: dict[str, Any] | None = None,
actor_kind: str = "user",
actor_ops_id: UUID | None = None,
) -> None:
digest = (
sha256(json.dumps([before, after], ensure_ascii=False, sort_keys=True, default=str))
@@ -38,6 +39,7 @@ async def record(
tenant_id=ctx.tenant_id,
actor_kind=actor_kind,
actor_user_id=ctx.user_id if actor_kind == "user" else None,
actor_ops_id=actor_ops_id,
actor_name=ctx.actor_name or None,
action=action,
object_type=object_type,

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from bid_doctor.billing import service
@@ -29,3 +31,42 @@ async def list_plans() -> dict[str, Any]:
async def get_quota(ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.overview(s, ctx)
@router.get("/quota/entries")
async def list_quota_entries(
ctx: Ctx,
unit: Literal["parse", "check", "generate", "dedupe"] | None = None,
project_id: UUID | None = None,
cursor: str | None = None,
limit: int = Query(default=20, ge=1, le=100),
) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.entries(s, ctx, unit=unit, project_id=project_id, cursor=cursor, limit=limit)
class InvoiceIn(BaseModel):
title: str = Field(min_length=2, max_length=100)
tax_no: str = Field(min_length=15, max_length=20)
invoice_type: Literal["general", "special"] = "general"
address_phone: str | None = Field(default=None, max_length=200)
bank_account: str | None = Field(default=None, max_length=200)
contact_name: str = Field(min_length=1, max_length=40)
contact_phone: str = Field(pattern=r"^1[3-9][0-9]{9}$")
class ActivationIn(BaseModel):
plan_code: Literal["pro"] = "pro"
invoice_info: InvoiceIn
@router.get("/activation-requests")
async def list_activation_requests(ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return {"items": [service.request_to_api(r) for r in await service.list_requests(s, ctx)]}
@router.post("/activation-requests", status_code=201)
async def create_activation_request(body: ActivationIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return service.request_to_api(await service.create_request(s, ctx, body.invoice_info.model_dump()))

View File

@@ -28,6 +28,7 @@ class Subscription(Base):
seats: Mapped[int] = mapped_column(server_default=FetchedValue())
starts_at: Mapped[Ts]
ends_at: Mapped[Ts | None]
activation_request_id: Mapped[UuidCol | None]
class QuotaAccount(Base):
@@ -56,4 +57,37 @@ class QuotaEntry(Base):
project_id: Mapped[UuidCol | None]
reason: Mapped[str | None]
actor_user_id: Mapped[UuidCol | None]
actor_ops_id: Mapped[UuidCol | None] = mapped_column(default=None)
created_at: Mapped[DbTs]
class ActivationRequest(Base):
"""专业版开通 / 续费申请(G8):提交 → 对公转账 → 运营确认到账后开通、开发票。注销后按财务要求保留。"""
__tablename__ = "activation_request"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
applicant_user_id: Mapped[UuidCol]
request_no: Mapped[str | None]
plan_code: Mapped[str]
plan_version: Mapped[int]
invoice_info: Mapped[Json]
status: Mapped[str] = mapped_column(server_default=FetchedValue())
ops_note: Mapped[str | None]
handled_by_ops: Mapped[UuidCol | None]
handled_at: Mapped[Ts | None]
invoice_no: Mapped[str | None]
invoiced_at: Mapped[Ts | None]
created_at: Mapped[DbTs]
updated_at: Mapped[DbTs]
class QuotaAlert(Base):
"""80% / 100% 提醒:每个订阅周期每档只提醒一次(主键保证)。"""
__tablename__ = "quota_alert"
tenant_id: Mapped[UuidCol] = mapped_column(primary_key=True)
account_id: Mapped[UuidCol] = mapped_column(primary_key=True)
subscription_id: Mapped[UuidCol] = mapped_column(primary_key=True)
threshold: Mapped[int] = mapped_column(primary_key=True)
sent_at: Mapped[DbTs]

View File

@@ -5,17 +5,27 @@
from __future__ import annotations
import base64
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy import func, select, text
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.billing.models import Plan, QuotaAccount, QuotaEntry, Subscription
from bid_doctor.billing.models import ActivationRequest, Plan, QuotaAccount, QuotaAlert, QuotaEntry, Subscription
from bid_doctor.core.config import get_settings
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.identity.models import Membership
from bid_doctor.notification.models import Notification
PERIOD_MONTHS = 12 # 专业版按年开通、续费(原型「专业版 · 1 年」)
# 现在用得上的额度单位:检查也从解析扣(data-model §4.3);套餐里的 generate(M4 标书生成)等功能上线再发放
LIVE_UNITS = ("parse",)
async def start_free_subscription(session: AsyncSession, ctx: TenantContext) -> None:
@@ -53,16 +63,24 @@ async def start_free_subscription(session: AsyncSession, ctx: TenantContext) ->
async def current_plan(session: AsyncSession, ctx: TenantContext) -> tuple[Subscription, Plan]:
"""当前这一期:生效中的;没有(专业版到期、还没续费)时取最近过期的那一期,只读(决策 M6-5)。"""
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")
.where(Subscription.tenant_id == ctx.tenant_id, Subscription.status.in_(("active", "expired")))
.order_by((Subscription.status == "active").desc(), Subscription.starts_at.desc())
.limit(1)
)
).one()
return row[0], row[1]
async def read_only(session: AsyncSession, ctx: TenantContext) -> bool:
sub, _ = await current_plan(session, ctx)
return sub.status == "expired"
async def hold(
session: AsyncSession, ctx: TenantContext, *, unit: str, ref_type: str, ref_id: UUID, project_id: UUID
) -> UUID:
@@ -87,9 +105,37 @@ async def hold(
ref_id=ref_id,
project_id=project_id,
)
await _alert(session, ctx, acct)
return entry.id
async def _alert(session: AsyncSession, ctx: TenantContext, acct: QuotaAccount) -> None:
"""用到本期发放的 80%、100% 时发站内提醒,每期每档一次(FR-M6-03;quota_alert 的主键保证不重复)。"""
sub, _ = await current_plan(session, ctx)
granted = (
await session.execute(
select(func.coalesce(func.sum(QuotaEntry.amount), 0)).where(
QuotaEntry.account_id == acct.id, QuotaEntry.subscription_id == sub.id, QuotaEntry.kind == "grant"
)
)
).scalar_one()
if not granted:
return
used = 1 - acct.balance / granted
for threshold in (80, 100):
if used * 100 < threshold:
continue
added = await session.execute(
insert(QuotaAlert)
.values(tenant_id=ctx.tenant_id, account_id=acct.id, subscription_id=sub.id, threshold=threshold)
.on_conflict_do_nothing()
.returning(QuotaAlert.threshold)
)
if added.first():
payload = {"unit": acct.unit, "balance": acct.balance, "granted": granted}
await notify(session, ctx, f"quota_{threshold}", payload, user_id=ctx.user_id)
async def balance(session: AsyncSession, ctx: TenantContext, unit: str) -> int:
acct = (
await session.execute(
@@ -170,7 +216,7 @@ async def _append(
kind=kind,
amount=amount,
idempotency_key=key,
actor_user_id=ctx.user_id,
actor_user_id=ctx.user_id if ctx.user_id.int else None, # 定时任务、运营操作没有用户
**fields,
)
session.add(entry)
@@ -181,20 +227,24 @@ async def _append(
async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
sub, plan = await current_plan(session, ctx)
# 每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 预扣数 − 退回数 − 结算数。
# TODO(专业版续费):按订阅周期切分「本期」;MVP 的免费版只有一个周期。
# 本期 = 这一期订阅的起止。每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 已用 − 结算数。
period = {"t": ctx.tenant_id, "s": sub.id, "start": sub.starts_at, "end": sub.ends_at}
# 本期的流水:发放按订阅算,用量按这一期的起止时间算
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),"
"WITH e AS (SELECT * FROM quota_entry"
" WHERE created_at >= :start AND (CAST(:end AS timestamptz) IS NULL OR created_at < :end))"
" SELECT a.unit, a.balance,"
" (SELECT coalesce(sum(g.amount), 0) FROM quota_entry g"
" WHERE g.account_id = a.id AND g.kind = 'grant' AND g.subscription_id = :s),"
" 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"
" FROM quota_account a LEFT JOIN e ON e.account_id = a.id"
" WHERE a.tenant_id = :t GROUP BY a.id, a.unit, a.balance ORDER BY a.unit"
),
{"t": ctx.tenant_id, "s": sub.id},
period,
)
).all()
accounts: list[dict[str, Any]] = []
@@ -207,10 +257,48 @@ async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
"held": used - settles,
"period_granted": granted,
"period_used": used,
"period_released": releases,
"used_ratio": round(used / granted, 3) if granted else 0.0,
}
)
return {"plan": plan_summary(sub, plan), "accounts": accounts}
# 按功能分布(原型额度页):没被退回的预扣按用途分;免扣另计
usage = dict.fromkeys(("tender", "addendum", "check", "deleted", "waived"), 0)
for cat, n in (
await session.execute(
text(
"WITH e AS (SELECT * FROM quota_entry"
" WHERE created_at >= :start AND (CAST(:end AS timestamptz) IS NULL OR created_at < :end))"
" SELECT CASE WHEN e.kind = 'waive' THEN 'waived' WHEN e.ref_type = 'check_run' THEN 'check'"
" WHEN d.role = 'tender' THEN 'tender' WHEN d.role IS NULL THEN 'deleted' ELSE 'addendum' END,"
" count(*) FROM e"
" LEFT JOIN parse_job j ON e.ref_type = 'parse_job' AND j.id = e.ref_id"
" LEFT JOIN source_document d ON d.id = j.document_id"
" WHERE e.kind IN ('hold', 'waive')"
" AND NOT EXISTS (SELECT 1 FROM quota_entry r WHERE r.hold_entry_id = e.id AND r.kind = 'release')"
" GROUP BY 1"
),
period,
)
).all():
usage[cat] = n
pending = await _open_request(session, ctx)
return {
"plan": plan_summary(sub, plan),
"accounts": accounts,
"usage": usage,
"renewal": await _renewal(session, ctx),
"pending_request": request_to_api(pending) if pending else None,
}
async def _renewal(session: AsyncSession, ctx: TenantContext) -> dict[str, Any] | None:
"""已经续费、排队等当前一期结束后生效的那一期。"""
nxt = (
await session.execute(
select(Subscription).where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "scheduled")
)
).scalar_one_or_none()
return {"starts_at": nxt.starts_at, "ends_at": nxt.ends_at} if nxt else None
def plan_summary(sub: Subscription, plan: Plan) -> dict[str, Any]:
@@ -219,5 +307,474 @@ def plan_summary(sub: Subscription, plan: Plan) -> dict[str, Any]:
"name": plan.name,
"starts_at": sub.starts_at,
"ends_at": sub.ends_at,
"read_only": False,
"read_only": sub.status == "expired",
}
async def notify(
session: AsyncSession, ctx: TenantContext, kind: str, payload: dict[str, Any], *, user_id: UUID | None = None
) -> None:
"""站内通知。没指定人时发给企业的所有者(定时任务、运营操作没有「当前用户」)。"""
users = [user_id] if user_id else await _owners(session, ctx)
for u in users:
session.add(Notification(id=uuid7(), tenant_id=ctx.tenant_id, user_id=u, kind=kind, payload=payload))
await session.flush()
async def _owners(session: AsyncSession, ctx: TenantContext) -> list[UUID]:
rows = await session.execute(
select(Membership.user_id).where(Membership.status == "active", Membership.role.in_(("owner", "admin")))
)
return list(rows.scalars())
# ---------------------------------------------------------------- 额度流水(FR-M6-03)
PARSE_LABEL = {"tender": "招标文件", "addendum": "补遗", "clarification": "澄清"}
def _cursor(entry_id: UUID) -> str:
return base64.urlsafe_b64encode(entry_id.bytes).decode().rstrip("=")
def _uncursor(cursor: str) -> UUID:
try:
return UUID(bytes=base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
except ValueError as e:
raise AppError("validation_failed", "分页参数不对") from e
async def entries(
session: AsyncSession,
ctx: TenantContext,
*,
unit: str | None = None,
project_id: UUID | None = None,
cursor: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
"""按时间倒序(uuid7 编号同一进程内单调,按编号排即按时间)。余额是这条记完以后的账户余额。
项目删了以后流水还在(G10):项目名称为空,界面写「已删除的项目」。"""
params = {
"limit": limit + 1,
"unit": unit,
"project": project_id,
"cursor": _uncursor(cursor) if cursor else None,
}
rows = (
await session.execute(
text(
"SELECT x.id, x.unit, x.kind, x.amount, x.balance_after, x.reason, x.created_at, x.project_id,"
" p.name, x.ref_type, x.ref_id, d.role, d.seq_no, c.run_no, x.hold_entry_id"
" FROM (SELECT e.*, a.unit,"
" sum(e.amount) OVER (PARTITION BY e.account_id ORDER BY e.id) AS balance_after"
" FROM quota_entry e JOIN quota_account a ON a.id = e.account_id) x"
" LEFT JOIN project p ON p.id = x.project_id"
" LEFT JOIN parse_job j ON x.ref_type = 'parse_job' AND j.id = x.ref_id"
" LEFT JOIN source_document d ON d.id = j.document_id"
" LEFT JOIN check_run c ON x.ref_type = 'check_run' AND c.id = x.ref_id"
" WHERE (CAST(:unit AS text) IS NULL OR x.unit = :unit)"
" AND (CAST(:project AS uuid) IS NULL OR x.project_id = :project)"
" AND (CAST(:cursor AS uuid) IS NULL OR x.id < :cursor)"
" ORDER BY x.id DESC LIMIT :limit"
),
params,
)
).all()
more = len(rows) > limit
rows = rows[:limit]
items = []
for r in rows:
(eid, u, kind, amount, bal, reason, at, pid, pname, ref_type, ref_id, role, seq, run_no, hold_id) = r
label = None
if ref_type == "parse_job" and role:
label = PARSE_LABEL.get(role, "文件") + (f" {seq}" if role != "tender" and seq else "")
elif ref_type == "check_run" and run_no:
label = f"第 {run_no} 次检查"
items.append(
{
"id": eid,
"unit": u,
"kind": kind,
"amount": amount,
"balance_after": bal,
"reason": reason,
"project": {"id": pid, "name": pname, "deleted": pname is None} if pid else None,
"ref": {"type": ref_type, "id": ref_id, "label": label} if ref_type else None,
"hold_entry_id": hold_id,
"created_at": at,
}
)
return {"items": items, "next_cursor": _cursor(rows[-1][0]) if more else None}
# ---------------------------------------------------------------- 开通申请(FR-M6-05、G8,决策 M6-6)
TAX_NO = re.compile(r"^[0-9A-Z]{15,20}$")
OPEN = ("submitted", "paid")
def payment_instructions() -> dict[str, str]:
s = get_settings()
return {
"payee_name": s.payee_name or "(待定)",
"payee_bank": s.payee_bank or "(待定)",
"payee_account": s.payee_account or "(待定)",
}
def request_to_api(r: ActivationRequest) -> dict[str, Any]:
return {
"id": r.id,
"request_no": r.request_no,
"plan_code": r.plan_code,
"status": r.status,
"invoice_info": r.invoice_info,
"payment": payment_instructions(),
"ops_note": r.ops_note,
"invoice_no": r.invoice_no,
"handled_at": r.handled_at,
"created_at": r.created_at,
}
async def _open_request(session: AsyncSession, ctx: TenantContext) -> ActivationRequest | None:
return (
await session.execute(
select(ActivationRequest)
.where(ActivationRequest.tenant_id == ctx.tenant_id, ActivationRequest.status.in_(OPEN))
.order_by(ActivationRequest.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
def _request_no(request_id: UUID) -> str:
"""申请单号,写在转账备注里:KT- 加编号随机部分的 6 位(大写字母和数字,去掉易混的 0 / O / 1 / I)。"""
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
n = request_id.int & ((1 << 60) - 1)
return "KT-" + "".join(alphabet[(n >> (5 * i)) & 31] for i in range(6))
async def create_request(session: AsyncSession, ctx: TenantContext, invoice: dict[str, Any]) -> ActivationRequest:
if await _open_request(session, ctx):
raise AppError("activation_pending")
tax_no = str(invoice.get("tax_no") or "").strip().upper()
if not TAX_NO.match(tax_no):
raise AppError("validation_failed", "纳税人识别号是 15 到 20 位数字或大写字母,请核对", field="tax_no")
if invoice.get("invoice_type") == "special" and not (invoice.get("address_phone") and invoice.get("bank_account")):
raise AppError("validation_failed", "增值税专用发票要填地址电话和开户行账号", field="bank_account")
plan = (
await session.execute(
select(Plan).where(Plan.code == "pro", Plan.active).order_by(Plan.version.desc()).limit(1)
)
).scalar_one()
rid = uuid7()
r = ActivationRequest(
id=rid,
tenant_id=ctx.tenant_id,
applicant_user_id=ctx.user_id,
request_no=_request_no(rid),
plan_code=plan.code,
plan_version=plan.version,
invoice_info={**{k: v for k, v in invoice.items() if v not in (None, "")}, "tax_no": tax_no},
)
session.add(r)
await session.flush()
await session.refresh(r)
return r
async def list_requests(session: AsyncSession, ctx: TenantContext) -> list[ActivationRequest]:
rows = await session.execute(select(ActivationRequest).order_by(ActivationRequest.created_at.desc()))
return list(rows.scalars())
def _add_months(at: datetime, months: int) -> datetime:
y, m = divmod(at.month - 1 + months, 12)
year, month = at.year + y, m + 1
for day in (at.day, 30, 29, 28): # 1 月 31 日加一个月 → 2 月最后一天
try:
return at.replace(year=year, month=month, day=day)
except ValueError:
continue
raise AssertionError("unreachable")
async def activate(
session: AsyncSession, ctx: TenantContext, request_id: UUID, *, ops_id: UUID, months: int = PERIOD_MONTHS
) -> Subscription:
"""运营确认到账后开通(G8)。
- 当前是免费版或已过期:马上开始新一期,免费版剩下的额度清零,发放专业版额度;
- 当前专业版还没到期(续费):新一期排队,从旧一期结束时开始,由定时任务生效、发放(旧一期剩下的不结转)。"""
r = (
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
).scalar_one()
if r.status not in OPEN:
raise AppError("invalid_transition", "这份申请已经处理过了")
plan = (
await session.execute(select(Plan).where(Plan.code == r.plan_code, Plan.version == r.plan_version))
).scalar_one()
now = datetime.now(UTC)
cur = (
await session.execute(
select(Subscription).where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "active")
)
).scalar_one_or_none()
renew = cur is not None and cur.plan_code != "free" and cur.ends_at is not None and cur.ends_at > now
if renew and await _renewal(session, ctx):
raise AppError("invalid_transition", "已经续过一期了,等它生效后再续")
start = cur.ends_at if renew and cur and cur.ends_at else now
sub = Subscription(
id=uuid7(),
tenant_id=ctx.tenant_id,
plan_code=plan.code,
plan_version=plan.version,
status="scheduled" if renew else "active",
source="manual",
starts_at=start,
ends_at=_add_months(start, months),
activation_request_id=r.id,
)
if not renew:
if cur is not None: # 免费版(或别的生效中的一期)结束,剩下的额度清零
await _expire_balances(session, ctx, cur, reason=f"开通专业版(申请单 {r.request_no}),原套餐结束")
cur.status, cur.ends_at = "cancelled", now
await session.flush()
session.add(sub)
await session.flush()
await _grant(session, ctx, sub, plan, reason=f"专业版开通(申请单 {r.request_no})")
else:
session.add(sub)
await session.flush()
r.status, r.handled_by_ops, r.handled_at = "activated", ops_id, now
await notify(
session,
ctx,
"subscription_activated",
{"request_no": r.request_no, "starts_at": start.isoformat(), "renewal": renew},
user_id=r.applicant_user_id,
)
await session.flush()
return sub
async def reject(session: AsyncSession, ctx: TenantContext, request_id: UUID, *, ops_id: UUID, note: str) -> None:
r = (
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
).scalar_one()
if r.status not in OPEN:
raise AppError("invalid_transition", "这份申请已经处理过了")
r.status, r.ops_note, r.handled_by_ops, r.handled_at = "rejected", note, ops_id, datetime.now(UTC)
await notify(session, ctx, "activation_rejected", {"request_no": r.request_no}, user_id=r.applicant_user_id)
async def _grant(session: AsyncSession, ctx: TenantContext, sub: Subscription, plan: Plan, *, reason: str) -> None:
for unit, amount in plan.quotas.items():
if unit not in LIVE_UNITS:
continue
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:
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=reason,
)
async def _expire_balances(session: AsyncSession, ctx: TenantContext, sub: Subscription, *, reason: str) -> None:
accts = (
await session.execute(select(QuotaAccount).where(QuotaAccount.tenant_id == ctx.tenant_id).with_for_update())
).scalars()
for acct in accts:
if acct.balance > 0:
await _append(
session,
ctx,
acct,
kind="expire",
amount=-acct.balance,
key=f"expire:{sub.id}:{acct.unit}",
subscription_id=sub.id,
reason=reason,
)
async def _open_holds(session: AsyncSession, ctx: TenantContext) -> int:
return (
await session.execute(
text(
"SELECT count(*) FROM quota_entry h WHERE h.kind = 'hold' AND NOT EXISTS"
" (SELECT 1 FROM quota_entry r WHERE r.hold_entry_id = h.id AND r.kind IN ('settle', 'release'))"
)
)
).scalar_one()
# ---------------------------------------------------------------- 到期与续费生效(定时任务,决策 M6-5)
REMIND_DAYS = (30, 7, 0)
async def due(session: AsyncSession, now: datetime) -> list[tuple[UUID, UUID, str]]:
"""跨企业找到期、该生效、该提醒的订阅:只拿编号(billing_due 是 bd_auth 所有的函数,data-model §2.3)。"""
rows = await session.execute(text("SELECT tenant_id, subscription_id, action FROM billing_due(:now)"), {"now": now})
return [(r[0], r[1], r[2]) for r in rows]
async def apply_due(session: AsyncSession, ctx: TenantContext, sub_id: UUID, action: str, now: datetime) -> None:
sub = (
await session.execute(select(Subscription).where(Subscription.id == sub_id).with_for_update())
).scalar_one_or_none()
if sub is None:
return
if action == "expire" and sub.status == "active":
if await _open_holds(session, ctx):
return # 还有没了结的预扣:等它结算或退回后(下一次定时任务)再清零,否则退回的那 1 次会漏在余额里
await _expire_balances(session, ctx, sub, reason="套餐到期")
sub.status = "expired"
await session.flush()
nxt = (
await session.execute(
select(Subscription).where(
Subscription.tenant_id == ctx.tenant_id,
Subscription.status == "scheduled",
Subscription.starts_at <= now,
)
)
).scalar_one_or_none()
if nxt is None:
await notify(
session, ctx, "subscription_expired", {"ends_at": sub.ends_at.isoformat() if sub.ends_at else None}
)
else:
await _start(session, ctx, nxt)
elif action == "start" and sub.status == "scheduled":
cur = (
await session.execute(
select(Subscription).where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "active")
)
).scalar_one_or_none()
if cur is None: # 旧一期已经结束(或者本来就没有):排队的这一期生效
await _start(session, ctx, sub)
elif action == "remind" and sub.status == "active" and sub.ends_at:
days = (sub.ends_at - now).days
marks = [d for d in REMIND_DAYS if days <= d]
if not marks or await _renewal(session, ctx):
return # 已经续费了就不提醒
mark = min(marks) # 提醒档位:30、7、当天各一次
sent = (
await session.execute(
select(func.count())
.select_from(Notification)
.where(
Notification.kind == "subscription_expiring",
Notification.payload["subscription_id"].astext == str(sub.id),
Notification.payload["mark"].astext == str(mark),
)
)
).scalar_one()
if not sent:
await notify(
session,
ctx,
"subscription_expiring",
{"subscription_id": str(sub.id), "mark": mark, "days": days, "ends_at": sub.ends_at.isoformat()},
)
async def _start(session: AsyncSession, ctx: TenantContext, sub: Subscription) -> None:
plan = (
await session.execute(select(Plan).where(Plan.code == sub.plan_code, Plan.version == sub.plan_version))
).scalar_one()
sub.status = "active"
await session.flush()
await _grant(session, ctx, sub, plan, reason="续费的新一期发放")
async def void_for_closure(session: AsyncSession, ctx: TenantContext) -> None:
"""注销账号:剩余额度作废(流水记「过期」),订阅都结束;开通申请和订阅记录保留(J4)。"""
subs = (
(
await session.execute(
select(Subscription).where(Subscription.status.in_(("active", "scheduled"))).with_for_update()
)
)
.scalars()
.all()
)
for sub in subs:
if sub.status == "active":
await _expire_balances(session, ctx, sub, reason="注销账号,剩余额度作废")
sub.status = "cancelled"
await session.flush()
# ---------------------------------------------------------------- 运营操作(FR-M6-09;ops 进程在租户事务里调用)
async def mark_paid(session: AsyncSession, ctx: TenantContext, request_id: UUID, *, ops_id: UUID) -> None:
r = (
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
).scalar_one()
if r.status != "submitted":
raise AppError("invalid_transition", "只有等待转账的申请能标记到账")
r.status, r.handled_by_ops, r.handled_at = "paid", ops_id, datetime.now(UTC)
async def set_invoice(session: AsyncSession, ctx: TenantContext, request_id: UUID, invoice_no: str) -> None:
r = (
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
).scalar_one()
if r.status != "activated":
raise AppError("invalid_transition", "开通以后才能登记发票")
r.invoice_no, r.invoiced_at = invoice_no, datetime.now(UTC)
async def adjust(
session: AsyncSession, ctx: TenantContext, *, unit: str, amount: int, reason: str, ops_id: UUID
) -> QuotaEntry:
"""人工调整额度(G6:MVP 不做线上加量包,运营按申请调)。原因必填;扣减不能扣成负数。"""
if not reason.strip():
raise AppError("validation_failed", "调整额度要写原因", field="reason")
if amount == 0:
raise AppError("validation_failed", "调整的数量不能是 0", field="amount")
sub, _ = await current_plan(session, ctx)
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:
acct = QuotaAccount(id=uuid7(), tenant_id=ctx.tenant_id, unit=unit, balance=0)
session.add(acct)
await session.flush()
if acct.balance + amount < 0:
raise AppError("validation_failed", f"余额只有 {acct.balance},不能扣成负数", field="amount")
return await _append(
session,
ctx,
acct,
kind="adjust",
amount=amount,
key=f"adjust:{uuid7()}",
subscription_id=sub.id,
reason=reason.strip(),
actor_ops_id=ops_id,
)

View File

@@ -0,0 +1,25 @@
"""额度和套餐的定时任务(决策 M6-5):worker 每小时跑一次 tick。"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import UUID
from bid_doctor.billing import service
from bid_doctor.core.db import TenantContext, tenant_tx
log = logging.getLogger(__name__)
SYSTEM = UUID(int=0)
async def tick(now: datetime) -> None:
async with tenant_tx(TenantContext(tenant_id=SYSTEM, user_id=SYSTEM)) as s:
due = await service.due(s, now)
for tenant_id, sub_id, action in due:
ctx = TenantContext(tenant_id=tenant_id, user_id=SYSTEM, actor_name="系统")
try:
async with tenant_tx(ctx) as s:
await service.apply_due(s, ctx, sub_id, action, now)
except Exception: # 一个企业出错不影响别的企业,下一次再试
log.exception("billing tick %s %s %s failed", tenant_id, sub_id, action)

View File

@@ -223,6 +223,7 @@ async def request_cancel(s: AsyncSession, run_id: UUID) -> CheckRun:
raise AppError("job_finished")
run.cancel_requested_at = datetime.now(UTC)
await s.flush()
await s.refresh(run) # 同解析的取消:触发器改过的列过期了,转 API 时不能再懒加载
return run

View File

@@ -26,6 +26,15 @@ def main() -> None:
api.add_argument("--host", default="127.0.0.1")
api.add_argument("--port", type=int, default=8009)
api.add_argument("--reload", action="store_true")
ops = sub.add_parser("ops", help="启动运营后台接口(单独端口,只对内网开放)")
ops.add_argument("--host", default="127.0.0.1")
ops.add_argument("--port", type=int, default=8019)
admin = sub.add_parser("ops-admin", help="管理运营账号")
admin_sub = admin.add_subparsers(dest="admin_cmd", required=True)
create = admin_sub.add_parser("create", help="新建运营账号(会提示输入密码,打印 TOTP 二维码地址)")
create.add_argument("--username", required=True)
create.add_argument("--display-name", required=True)
create.add_argument("--role", default="admin", help="角色(现在只有 admin)")
sub.add_parser("worker", help="启动后台任务进程")
sub.add_parser("migrate", help="把数据库升级到最新版本")
args = parser.parse_args()
@@ -34,6 +43,21 @@ def main() -> None:
import uvicorn
uvicorn.run("bid_doctor.app:create_app", factory=True, host=args.host, port=args.port, reload=args.reload)
elif args.cmd == "ops":
import uvicorn
uvicorn.run("bid_doctor.ops.app:create_ops_app", factory=True, host=args.host, port=args.port)
elif args.cmd == "ops-admin":
import getpass
from bid_doctor.ops.auth import create_admin
password = getpass.getpass("密码(至少 12 位):")
if password != getpass.getpass("再输一次:"):
raise SystemExit("两次输入的密码不一样")
uri = asyncio.run(create_admin(args.username, args.display_name, password, args.role))
print("用身份验证器 App 添加这个账号(只显示这一次):")
print(uri)
elif args.cmd == "worker":
from bid_doctor.worker import run_worker

View File

@@ -20,6 +20,8 @@ class Settings(BaseSettings):
# api / worker 用 bd_app(受行级安全策略约束);迁移用 bd_owner
database_url: str = "postgresql+psycopg://bd_app:dev-app@127.0.0.1:54329/bid_doctor"
migrate_database_url: str = "postgresql+psycopg://bd_owner:dev-owner@127.0.0.1:54329/bid_doctor"
# 运营后台的跨企业查询用 bd_ops(绕过行级安全策略,只读 + 运营账号表);对某个企业的操作仍走 bd_app(决策 M6-2)
ops_database_url: str = "postgresql+psycopg://bd_ops:dev-ops@127.0.0.1:54329/bid_doctor"
valkey_url: str = "redis://127.0.0.1:63799/0"
gotenberg_url: str = "http://127.0.0.1:3109"
@@ -49,6 +51,16 @@ class Settings(BaseSettings):
# 每次调用的输出上限(token)。输出用到 92% 以上就按截断处理、拆小重跑(ai/providers/base.near_limit)
ai_max_output_tokens: int = 8192
# 专业版对公转账的收款信息(决策 M6-6):不进代码;没配时开通申请里显示「(待定)」,生产环境必须配
payee_name: str = ""
payee_bank: str = ""
payee_account: str = ""
# 运营后台(决策 M6-4):只放行这些来源地址(CIDR);会话时长;前端单独部署时的来源
ops_allowed_ips: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
ops_session_ttl_hours: int = 8
ops_cors_origins: list[str] = Field(default_factory=lambda: ["http://127.0.0.1:5174", "http://localhost:5174"])
upload_part_size: int = 8 * 1024 * 1024
download_url_ttl_s: int = 300
@@ -69,6 +81,10 @@ class Settings(BaseSettings):
raise ValueError("生产环境不能使用假模型")
if not self.cookie_secure:
raise ValueError("生产环境必须开启 cookie_secure")
if not self.ops_allowed_ips or any(c in ("0.0.0.0/0", "::/0") for c in self.ops_allowed_ips):
raise ValueError("生产环境要给运营后台配置 IP 白名单(BD_OPS_ALLOWED_IPS),不能放开全部地址")
if not (self.payee_name and self.payee_bank and self.payee_account):
raise ValueError("生产环境要配置对公转账的收款信息(BD_PAYEE_NAME / BANK / ACCOUNT)")
return self

View File

@@ -92,3 +92,43 @@ def verify_download(payload: str, exp: int, sig: str) -> bool:
return False
mac = hmac.new(_key(get_settings().secret_key, "download"), f"{payload}|{exp}".encode(), hashlib.sha256).digest()
return hmac.compare_digest(base64.urlsafe_b64encode(mac).decode().rstrip("="), sig)
# ---- 运营账号(决策 M6-4):密码 scrypt;TOTP 密钥用主密钥加密存库
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
return "scrypt$16384$8$1$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()
def verify_password(password: str, stored: str) -> bool:
try:
_, n, r, p, salt, digest = stored.split("$")
got = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt), n=int(n), r=int(r), p=int(p))
except (ValueError, TypeError):
return False
return hmac.compare_digest(got, base64.b64decode(digest))
def seal_secret(value: bytes) -> bytes:
return _seal(_key(get_settings().master_key, "ops-totp"), value)
def open_secret(sealed: bytes) -> bytes:
return _open(_key(get_settings().master_key, "ops-totp"), sealed)
def totp(secret: bytes, at: float | None = None, step: int = 30) -> str:
"""RFC 6238:6 位,30 秒一换(和常见的身份验证器 App 一致)。"""
counter = int((time.time() if at is None else at) // step)
mac = hmac.new(secret, counter.to_bytes(8, "big"), hashlib.sha1).digest()
offset = mac[-1] & 0x0F
code = (int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF) % 1_000_000
return f"{code:06d}"
def verify_totp(secret: bytes, code: str, at: float | None = None) -> bool:
now = time.time() if at is None else at
return any(hmac.compare_digest(totp(secret, now + d * 30), code) for d in (-1, 0, 1)) # 前后各容 30 秒

View File

@@ -66,3 +66,21 @@ async def anonymous_tx() -> AsyncGenerator[AsyncSession]:
async def set_tenant(session: AsyncSession, tenant_id: UUID) -> None:
"""注册时先建租户再写它的数据:在同一个事务里切换到新租户。"""
await session.execute(text("SELECT set_config('app.tenant_id', :t, true)"), {"t": str(tenant_id)})
@lru_cache
def _ops_sessions() -> async_sessionmaker[AsyncSession]:
eng = create_async_engine(
get_settings().ops_database_url,
pool_size=4,
pool_pre_ping=True,
connect_args={"options": "-c search_path=app,public"},
)
return async_sessionmaker(eng, expire_on_commit=False)
@asynccontextmanager
async def ops_tx() -> AsyncGenerator[AsyncSession]:
"""运营后台的跨企业查询(bd_ops,绕过行级安全策略)。只给 ops 进程用;对某个企业做的修改仍用 tenant_tx。"""
async with _ops_sessions()() as session, session.begin():
yield session

View File

@@ -16,6 +16,8 @@ CODES: dict[str, tuple[int, str]] = {
"session_revoked": (401, "登录已失效,请重新登录"),
"csrf_failed": (403, "请求校验失败,请刷新页面后重试"),
"forbidden": (403, "没有权限进行这个操作"),
"ops_ip_denied": (403, "这个地址不能访问运营后台"),
"ops_login_failed": (401, "用户名、密码或动态验证码不对"),
"not_found": (404, "没有找到"),
"phone_not_registered": (404, "这个手机号还没有注册"),
"phone_already_registered": (409, "这个手机号已经注册"),
@@ -26,6 +28,9 @@ CODES: dict[str, tuple[int, str]] = {
"job_already_running": (409, "任务正在进行"),
"job_finished": (409, "任务已经结束"),
"project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"),
"plan_expired": (409, "套餐已到期,数据只读;续费到账后恢复"),
"activation_pending": (409, "已经有一份待处理的开通申请"),
"tenant_name_mismatch": (422, "企业名称不一致,请照上面的名称输入"),
"not_superseded": (409, "这一条没有待确认的补遗更新"),
"no_pending_value": (409, "这个字段没有待确认的补遗新值"),
"document_role_not_parsable": (409, "这类文件不需要单独解析"),

View File

@@ -1,16 +1,31 @@
"""UUIDv7:按时间有序、不可猜测(架构 §9)。Python 3.14 起标准库自带 uuid.uuid7,这里兼容 3.12。"""
"""UUIDv7:按时间有序、不可猜测(架构 §9)。Python 3.14 起标准库自带 uuid.uuid7,这里兼容 3.12。
同一进程内单调递增(RFC 9562 §6.2 方法 2:同一毫秒内接着上一个加一):额度流水按编号排序算余额,
同一个事务里先后写的两条(开通时先清掉免费版余额、再发放专业版额度)顺序不能乱。
"""
from __future__ import annotations
import os
import threading
import time
import uuid
_lock = threading.Lock()
_last = 0
def _stamp(value: int) -> int:
value = (value & ~(0xF << 76)) | (0x7 << 76) # 版本 7
return (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 4122 变体
def uuid7() -> uuid.UUID:
global _last
ms = time.time_ns() // 1_000_000
rand = int.from_bytes(os.urandom(10), "big")
value = (ms & 0xFFFF_FFFF_FFFF) << 80 | rand
value = (value & ~(0xF << 76)) | (0x7 << 76) # 版本 7
value = (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 4122 变体
value = _stamp((ms & 0xFFFF_FFFF_FFFF) << 80 | int.from_bytes(os.urandom(10), "big"))
with _lock:
if value <= _last: # 同一毫秒(或时钟回拨):排在上一个后面,只动最低的 62 位随机部分
value = _last + 1
_last = value
return uuid.UUID(int=value)

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.core.crypto import KEK_REF, new_data_key, unwrap_data_key
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
@@ -26,6 +27,17 @@ async def data_key(session: AsyncSession, ctx: TenantContext, project_id: UUID |
).first()
if row:
return unwrap_data_key(row[0])
destroyed = (
await session.execute(
text(
"SELECT 1 FROM data_key WHERE tenant_id = :t AND scope = :s "
"AND project_id IS NOT DISTINCT FROM :p AND destroyed_at IS NOT NULL LIMIT 1"
),
{"t": ctx.tenant_id, "s": scope, "p": project_id},
)
).first()
if destroyed: # 项目删了、账号注销了(G10):不再建新密钥,免得还在跑的任务把文件写回去
raise AppError("not_found")
plain, wrapped = new_data_key()
await session.execute(
text(
@@ -45,3 +57,13 @@ async def destroy_project_key(session: AsyncSession, ctx: TenantContext, project
),
{"t": ctx.tenant_id, "p": project_id},
)
async def destroy_tenant_keys(session: AsyncSession, ctx: TenantContext) -> None:
"""注销账号:企业级和所有项目的密钥一起销毁(G10),备份里的文件随之不可读。"""
await session.execute(
text(
"UPDATE data_key SET wrapped_key = NULL, destroyed_at = now() WHERE tenant_id = :t AND destroyed_at IS NULL"
),
{"t": ctx.tenant_id},
)

View File

@@ -69,3 +69,7 @@ def upload_prefix(tenant_id: object, upload_id: object) -> str:
def qualification_prefix(tenant_id: object, qualification_id: object) -> str:
return f"t/{tenant_id}/q/{qualification_id}"
def tenant_prefix(tenant_id: object) -> str:
return f"t/{tenant_id}"

View File

@@ -188,6 +188,7 @@ async def request_cancel(s: AsyncSession, job_id: UUID) -> ParseJob:
raise AppError("job_finished")
job.cancel_requested_at = datetime.now(UTC)
await s.flush()
await s.refresh(job) # 触发器改过的列(updated_at)过期了,转 API 时不能再懒加载
return job

View File

@@ -5,7 +5,7 @@ from typing import Any, Literal
from fastapi import APIRouter, Request, Response
from pydantic import BaseModel, Field
from bid_doctor.identity import service, sms
from bid_doctor.identity import closure, service, sms
from bid_doctor.identity.deps import Auth, clear_session_cookies, set_session_cookies
router = APIRouter(tags=["auth", "me"])
@@ -79,3 +79,24 @@ async def logout(p: Auth, response: Response) -> None:
@router.get("/me")
async def get_me(p: Auth) -> dict[str, Any]:
return await service.me(p)
class MeSmsCodeIn(BaseModel):
purpose: Literal["close_tenant"]
@router.post("/me/sms-codes", status_code=202)
async def send_my_sms_code(body: MeSmsCodeIn, p: Auth) -> dict[str, int]:
"""登录后的二次确认(注销账号):验证码发到自己的手机号,前端不用知道完整号码。"""
return {"resend_after_s": await sms.send(await closure.own_phone(p.ctx.user_id), body.purpose)}
class CloseTenantIn(BaseModel):
sms_code: str = Field(pattern=CODE)
confirm_tenant_name: str = Field(min_length=1, max_length=100)
@router.post("/tenant/close", status_code=202)
async def close_tenant(body: CloseTenantIn, p: Auth, response: Response) -> None:
await closure.close(p, body.sms_code, body.confirm_tenant_name)
clear_session_cookies(response)

View File

@@ -0,0 +1,111 @@
"""注销账号(J4、data-model §6.2,决策 M6-8)。MVP 一个企业只有一个成员,注销账号就是注销企业。
请求里(同步):核对短信验证码和企业名称 → 剩余额度作废、订阅结束 → 写审计 → 企业标「已注销」、吊销所有会话、
成员身份改「已离开」→ 删登录方式、匿名化人(不再属于任何企业时)。这一步完成后手机号就可以重新注册。
后台(close_tenant 任务):逐个删项目(同删除项目)、删资质库和通知、抹掉审计内容、销毁全部密钥、清对象存储。
开通申请、订阅、额度流水按财务要求保留(不含业务内容)。
"""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import delete, select, text, update
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.core import keys, queue
from bid_doctor.core.authz import Permission, require
from bid_doctor.core.crypto import decrypt_contact
from bid_doctor.core.db import TenantContext, anonymous_tx, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.storage import storage, tenant_prefix
from bid_doctor.identity import sms
from bid_doctor.identity.models import LoginIdentity, Tenant, UserSession
from bid_doctor.identity.service import Principal
from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
async def own_phone(user_id: UUID) -> str:
async with anonymous_tx() as s:
sealed = (
await s.execute(
select(LoginIdentity.contact_cipher).where(
LoginIdentity.user_id == user_id, LoginIdentity.provider == "phone"
)
)
).scalar_one_or_none()
if not sealed:
raise AppError("not_found")
return decrypt_contact(sealed)
async def close(p: Principal, sms_code: str, confirm_tenant_name: str) -> None:
require(p.ctx, Permission.TENANT_CLOSE)
ctx = p.ctx
async with tenant_tx(ctx) as s:
tenant = (await s.execute(select(Tenant).where(Tenant.id == ctx.tenant_id))).scalar_one()
if confirm_tenant_name.strip() != tenant.name:
raise AppError("tenant_name_mismatch")
await sms.verify(await own_phone(ctx.user_id), "close_tenant", sms_code)
now = datetime.now(UTC)
async with tenant_tx(ctx) as s:
tenant = (await s.execute(select(Tenant).where(Tenant.id == ctx.tenant_id).with_for_update())).scalar_one()
await billing.void_for_closure(s, ctx)
await audit.record(s, ctx, "tenant.close", "tenant", tenant.id)
tenant.status = "closed"
await s.execute(text("UPDATE tenant SET closed_at = now() WHERE id = :t"), {"t": tenant.id})
await s.execute(
update(UserSession)
.where(UserSession.revoked_at.is_(None))
.values(revoked_at=now, revoke_reason="tenant_closed")
)
await s.execute(text("UPDATE membership SET status = 'left', left_at = now() WHERE status <> 'left'"))
await queue.enqueue(s, "close_tenant", {"tenant_id": str(tenant.id)}, lock=f"close:{tenant.id}")
# 人和登录方式是全局表:这个人不再属于任何企业时删登录方式(手机号可以重新注册)、匿名化
async with anonymous_tx() as s:
others = (
await s.execute(
text("SELECT count(*) FROM auth_list_memberships(:u) WHERE status = 'active'"), {"u": ctx.user_id}
)
).scalar_one()
if not others:
await s.execute(delete(LoginIdentity).where(LoginIdentity.user_id == ctx.user_id))
await s.execute(
text(
"UPDATE user_account SET display_name = '已注销用户', status = 'anonymized', anonymized_at = now()"
" WHERE id = :u"
),
{"u": ctx.user_id},
)
async def finish(tenant_id: UUID) -> None:
"""后台:删光业务数据。可以重复执行(任务重试、中途失败后再跑)。"""
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0), actor_name="系统")
async with tenant_tx(ctx) as s:
status = (await s.execute(select(Tenant.status).where(Tenant.id == tenant_id))).scalar_one()
if status != "closed":
return
ids = list((await s.execute(select(Project.id))).scalars())
for pid in ids: # 一个项目一个事务:大企业也不会一个事务跑太久
async with tenant_tx(ctx) as s:
p = (await s.execute(select(Project).where(Project.id == pid))).scalar_one_or_none()
if p is not None:
await projects.remove(s, ctx, p, actor_kind="system")
async with tenant_tx(ctx) as s:
await s.execute(text("DELETE FROM qualification"))
await s.execute(text("DELETE FROM notification"))
await s.execute(text("DELETE FROM feedback"))
# 审计只留动作、时间、对象编号:内容和姓名快照都抹掉(触发器只允许这一种修改)
await s.execute(
text(
"UPDATE audit_event SET before = NULL, after = NULL, actor_name = NULL,"
" content_scrubbed_at = coalesce(content_scrubbed_at, now())"
" WHERE before IS NOT NULL OR after IS NOT NULL OR actor_name IS NOT NULL"
)
)
await keys.destroy_tenant_keys(s, ctx)
storage().delete_prefix(tenant_prefix(tenant_id))

View File

@@ -7,15 +7,27 @@ from typing import Annotated
from fastapi import Depends, Request, Response
from bid_doctor.billing import service as billing
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import new_token
from bid_doctor.core.db import TenantContext
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.identity.service import Principal, resolve, why_invalid
SESSION_COOKIE = "bd_session"
CSRF_COOKIE = "bd_csrf"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# 套餐到期后只读(决策 M6-5):这些写操作照常放行——退出、续费、删除项目、注销、通知已读、反馈、管理登录设备
READ_ONLY_ALLOWED = {
("POST", "/auth/logout"),
("POST", "/activation-requests"),
("DELETE", "/projects/{project_id}"),
("POST", "/me/sms-codes"),
("POST", "/tenant/close"),
("POST", "/notifications/read"),
("POST", "/feedback"),
("DELETE", "/me/sessions/{session_id}"),
}
async def current_principal(request: Request) -> Principal:
@@ -34,9 +46,19 @@ async def current_principal(request: Request) -> Principal:
if p is None:
code, reason = await why_invalid(token)
raise AppError(code, **({"reason": reason} if reason else {}))
if request.method not in SAFE_METHODS and (request.method, _route(request)) not in READ_ONLY_ALLOWED:
async with tenant_tx(p.ctx) as s:
if await billing.read_only(s, p.ctx):
raise AppError("plan_expired")
return p
def _route(request: Request) -> str:
route = request.scope.get("route")
path = str(getattr(route, "path", request.url.path))
return path.removeprefix("/api/v1") # app.API_PREFIX(这里不 import app,避免循环)
async def current_ctx(p: Annotated[Principal, Depends(current_principal)]) -> TenantContext:
return p.ctx

View File

@@ -1,15 +1,18 @@
from __future__ import annotations
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Header
from fastapi import APIRouter, Header, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from pydantic import BaseModel, Field
from sqlalchemy import func, select, update
from bid_doctor.core import events
from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx
from bid_doctor.notification.models import Notification
from bid_doctor.project.models import Project
router = APIRouter(tags=["notifications"])
@@ -24,13 +27,56 @@ async def stream_events(ctx: Ctx, last_event_id: Annotated[str | None, Header()]
)
QUOTA_UNIT = {"parse": "解析", "check": "检查", "generate": "生成", "dedupe": "查重"}
def _title(kind: str, payload: dict[str, Any]) -> str:
unit = QUOTA_UNIT.get(str(payload.get("unit")), "")
days = payload.get("days")
fixed = {
"parse_succeeded": "招标文件解析完成",
"parse_failed": "招标文件解析失败,额度已退回",
"check_succeeded": "投标文件检查完成",
"check_failed": "投标文件检查没有完成,不扣额度",
"report_succeeded": "检查报告已生成",
"report_failed": "检查报告生成失败",
"quota_80": f"本期{unit}额度已用到 80%",
"quota_100": f"本期{unit}额度已用完",
"subscription_activated": "续费已到账,新一期到期后接上" if payload.get("renewal") else "专业版已开通",
"subscription_expiring": "专业版今天到期" if days == 0 else f"专业版还有 {days} 天到期",
"subscription_expired": "专业版已到期,数据只读;续费后恢复",
"activation_rejected": f"开通申请 {payload.get('request_no') or ''} 被退回",
}
return fixed.get(kind, kind)
def _link(kind: str, project_id: UUID | None, payload: dict[str, Any]) -> str | None:
if kind.startswith(("quota_", "subscription_", "activation_")):
return "/quota"
if project_id is None:
return None
if kind == "check_succeeded" and payload.get("run_id"):
return f"/projects/{project_id}/results/{payload['run_id']}"
if kind.startswith("report_"):
return f"/projects/{project_id}/report"
return f"/projects/{project_id}/checklist"
@router.get("/notifications")
async def list_notifications(ctx: Ctx, unread: bool | None = None) -> dict[str, Any]:
async def list_notifications(
ctx: Ctx, unread: bool | None = None, cursor: UUID | None = None, limit: int = Query(default=20, ge=1, le=100)
) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
q = select(Notification).where(Notification.user_id == ctx.user_id)
q = (
select(Notification, Project.name)
.outerjoin(Project, Project.id == Notification.project_id)
.where(Notification.user_id == ctx.user_id)
)
if unread:
q = q.where(Notification.read_at.is_(None))
rows = (await s.execute(q.order_by(Notification.created_at.desc()).limit(50))).scalars().all()
if cursor:
q = q.where(Notification.id < cursor)
rows = (await s.execute(q.order_by(Notification.id.desc()).limit(limit + 1))).all()
n = (
await s.execute(
select(func.count())
@@ -38,27 +84,35 @@ async def list_notifications(ctx: Ctx, unread: bool | None = None) -> dict[str,
.where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
)
).scalar_one()
titles = {
"parse_succeeded": "招标文件解析完成",
"parse_failed": "招标文件解析失败,额度已退回",
"check_succeeded": "投标文件检查完成",
"check_failed": "投标文件检查没有完成,不扣额度",
"report_succeeded": "检查报告已生成",
"report_failed": "检查报告生成失败",
}
more = len(rows) > limit
rows = rows[:limit]
return {
"unread_count": n,
"next_cursor": None,
"next_cursor": str(rows[-1][0].id) if more else None,
"items": [
{
"id": r.id,
"kind": r.kind,
"title": titles.get(r.kind, r.kind),
"title": _title(r.kind, r.payload or {}),
"project_id": r.project_id,
"link": f"/projects/{r.project_id}/checklist" if r.project_id else None,
"project_name": name,
"link": _link(r.kind, r.project_id, r.payload or {}),
"read_at": r.read_at,
"created_at": r.created_at,
}
for r in rows
for r, name in rows
],
}
class ReadIn(BaseModel):
ids: list[UUID] | None = Field(default=None, max_length=200)
@router.post("/notifications/read", status_code=204)
async def mark_read(ctx: Ctx, body: ReadIn | None = None) -> None:
async with tenant_tx(ctx) as s:
q = update(Notification).where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
if body and body.ids:
q = q.where(Notification.id.in_(body.ids))
await s.execute(q.values(read_at=func.now()))

View File

@@ -0,0 +1,52 @@
"""ops 进程:运营后台接口(决策 M6-2)。和 api、worker 同一个包、同一个镜像,单独端口;部署时这个端口只对内网开放,
再加一层 IP 白名单(决策 M6-4)。用户端的 api 进程里没有这些接口。"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from bid_doctor.core import errors
from bid_doctor.core.config import get_settings
from bid_doctor.core.ids import uuid7
OPS_PREFIX = "/ops/v1"
def create_ops_app() -> FastAPI:
from bid_doctor.ops.auth import ip_allowed
from bid_doctor.ops.console_api import router
settings = get_settings()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
app = FastAPI(title="标博士运营后台接口", version="0.1.0", openapi_url=None, docs_url=None, redoc_url=None)
errors.install(app)
@app.middleware("http")
async def guard(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: # pyright: ignore[reportUnusedFunction]
rid = request.headers.get("x-request-id") or f"req_{uuid7().hex}"
request.state.request_id = rid
if not ip_allowed(request.client.host if request.client else None):
return errors.problem(request, "ops_ip_denied")
response = await call_next(request)
response.headers["X-Request-ID"] = rid
response.headers["Cache-Control"] = "no-store"
return response
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ops_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router, prefix=OPS_PREFIX)
@app.get("/healthz", include_in_schema=False)
async def healthz() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
return {"status": "ok"}
return app

View File

@@ -0,0 +1,180 @@
"""运营后台的登录(决策 M6-4):用户名 + 密码 + TOTP,IP 白名单;账号用命令行建,没有自助注册。
会话令牌只发一次,库里存 SHA-256;cookie bd_ops_session(HttpOnly、SameSite=Strict)+ CSRF 头,和用户端同一套做法。
"""
from __future__ import annotations
import base64
import hmac
import ipaddress
import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Annotated
from urllib.parse import quote
from uuid import UUID
from fastapi import Depends, Request, Response
from sqlalchemy import select, update
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import (
hash_password,
new_token,
open_secret,
seal_secret,
sha256,
verify_password,
verify_totp,
)
from bid_doctor.core.db import ops_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.core.valkey import hit_limit
from bid_doctor.ops.models import OpsAdmin, OpsSession
SESSION_COOKIE = "bd_ops_session"
CSRF_COOKIE = "bd_ops_csrf"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
class OpsPermission(StrEnum):
"""运营后台的权限点(只留结构):每个接口都过检查点,前端菜单按它过滤。"""
TENANT_VIEW = "tenant.view"
QUOTA_ADJUST = "quota.adjust"
ACTIVATION_MANAGE = "activation.manage"
INVOICE_MANAGE = "invoice.manage"
FEEDBACK_VIEW = "feedback.view"
JOBS_VIEW = "jobs.view"
AUDIT_VIEW = "audit.view"
# 角色 → 权限点。现在只有管理员;以后加运营、销售、运维等角色时只改这里(表里的 role 不加 CHECK)
ROLE_PERMISSIONS: dict[str, frozenset[OpsPermission]] = {"admin": frozenset(OpsPermission)}
ROLE_LABEL = {"admin": "管理员"}
@dataclass(frozen=True, slots=True)
class Operator:
id: UUID
username: str
display_name: str
session_id: UUID
role: str = "admin"
@property
def permissions(self) -> frozenset[OpsPermission]:
return ROLE_PERMISSIONS.get(self.role, frozenset())
def require(op: Operator, permission: OpsPermission) -> None:
if permission not in op.permissions:
raise AppError("forbidden")
def ip_allowed(ip: str | None) -> bool:
if not ip:
return False
addr = ipaddress.ip_address(ip)
return any(addr in ipaddress.ip_network(c, strict=False) for c in get_settings().ops_allowed_ips)
async def create_admin(username: str, display_name: str, password: str, role: str = "admin") -> str:
"""命令行建运营账号。返回 otpauth:// 地址,用身份验证器 App 扫码(只显示这一次)。"""
if len(password) < 12:
raise ValueError("密码至少 12 位")
if role not in ROLE_PERMISSIONS:
raise ValueError(f"没有这个角色:{role}(现有:{', '.join(ROLE_PERMISSIONS)})")
secret = os.urandom(20)
async with ops_tx() as s:
s.add(
OpsAdmin(
id=uuid7(),
username=username,
display_name=display_name,
role=role,
secret_hash=hash_password(password),
totp_cipher=seal_secret(secret),
)
)
b32 = base64.b32encode(secret).decode().rstrip("=")
return f"otpauth://totp/{quote('标博士运营:' + username)}?secret={b32}&issuer={quote('标博士运营')}"
async def login(username: str, password: str, code: str, ip: str) -> tuple[str, Operator]:
wait = await hit_limit(f"ops:login:{username}", 5, 900) # 15 分钟内最多试 5 次
if wait:
raise AppError("rate_limited", retry_after_s=wait)
async with ops_tx() as s:
admin = (await s.execute(select(OpsAdmin).where(OpsAdmin.username == username))).scalar_one_or_none()
ok = (
admin is not None
and admin.status == "active"
and verify_password(password, admin.secret_hash)
and verify_totp(open_secret(admin.totp_cipher), code)
)
if not ok or admin is None:
raise AppError("ops_login_failed")
token = new_token()
sess = OpsSession(
id=uuid7(),
admin_id=admin.id,
token_hash=sha256(token),
ip=ip,
expires_at=datetime.now(UTC) + timedelta(hours=get_settings().ops_session_ttl_hours),
)
s.add(sess)
await s.flush()
return token, Operator(admin.id, admin.username, admin.display_name, sess.id, admin.role)
async def logout(op: Operator) -> None:
async with ops_tx() as s:
await s.execute(update(OpsSession).where(OpsSession.id == op.session_id).values(revoked_at=datetime.now(UTC)))
async def current_operator(request: Request) -> Operator:
token = request.cookies.get(SESSION_COOKIE, "")
if token and request.method not in SAFE_METHODS:
sent, expected = request.headers.get("x-csrf-token", ""), request.cookies.get(CSRF_COOKIE, "")
if not expected or not hmac.compare_digest(sent, expected):
raise AppError("csrf_failed")
if not token:
raise AppError("unauthenticated")
async with ops_tx() as s:
row = (
await s.execute(
select(OpsSession, OpsAdmin)
.join(OpsAdmin, OpsAdmin.id == OpsSession.admin_id)
.where(
OpsSession.token_hash == sha256(token),
OpsSession.revoked_at.is_(None),
OpsSession.expires_at > datetime.now(UTC),
OpsAdmin.status == "active",
)
)
).first()
if row is None:
raise AppError("session_expired")
sess, admin = row
return Operator(admin.id, admin.username, admin.display_name, sess.id, admin.role)
Op = Annotated[Operator, Depends(current_operator)]
def set_cookies(response: Response, token: str) -> None:
s = get_settings()
age = s.ops_session_ttl_hours * 3600
response.set_cookie(SESSION_COOKIE, token, max_age=age, httponly=True, secure=s.cookie_secure, samesite="strict")
response.set_cookie(
CSRF_COOKIE, new_token(), max_age=age, httponly=False, secure=s.cookie_secure, samesite="strict"
)
def clear_cookies(response: Response) -> None:
response.delete_cookie(SESSION_COOKIE)
response.delete_cookie(CSRF_COOKIE)

View File

@@ -0,0 +1,336 @@
"""运营后台的查询(FR-M6-09,m6-plan §2.2):跨企业,用 bd_ops 只读(ops_tx)。
不显示业务内容(决策 M6-7、红线 10):项目名称、招标 / 投标原文、审计里的修改前后都不出现;
反馈只给标题、理由、规则编号、提示词版本、页码。查看企业详情也写一条审计。
"""
from __future__ import annotations
import re
from typing import Any
from uuid import UUID
from sqlalchemy import text
from bid_doctor.audit.models import AuditEvent
from bid_doctor.core.crypto import phone_lookup_key
from bid_doctor.core.db import ops_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
PHONE = re.compile(r"^1[3-9]\d{9}$")
TENANT_ROW = """
SELECT t.id, t.name, t.status, t.created_at, s.plan_code, s.status AS plan_status, s.ends_at,
(SELECT a.balance FROM quota_account a WHERE a.tenant_id = t.id AND a.unit = 'parse') AS balance,
(SELECT count(*) FROM project p WHERE p.tenant_id = t.id) AS projects,
(SELECT li.contact_hint FROM membership m JOIN login_identity li ON li.user_id = m.user_id
WHERE m.tenant_id = t.id AND m.role = 'owner' AND li.provider = 'phone' LIMIT 1) AS phone_hint,
(SELECT count(*) FROM activation_request r WHERE r.tenant_id = t.id AND r.status IN ('submitted', 'paid'))
AS open_requests
FROM tenant t
LEFT JOIN LATERAL (
SELECT plan_code, status, ends_at FROM subscription x
WHERE x.tenant_id = t.id AND x.status IN ('active', 'expired')
ORDER BY (x.status = 'active') DESC, x.starts_at DESC LIMIT 1
) s ON true
"""
def _tenant(r: Any) -> dict[str, Any]:
return {
"id": r.id,
"name": r.name,
"status": r.status,
"created_at": r.created_at,
"plan": {"code": r.plan_code, "expired": r.plan_status == "expired", "ends_at": r.ends_at},
"balance": r.balance,
"projects": r.projects,
"phone_hint": r.phone_hint,
"open_requests": r.open_requests,
}
async def search_tenants(q: str) -> list[dict[str, Any]]:
"""按企业名称(模糊)、手机号(精确,用 HMAC 查)、申请单号搜;不带条件时列最近注册的。"""
q = q.strip()
async with ops_tx() as s:
if PHONE.match(q):
where, params = (
"WHERE t.id IN (SELECT m.tenant_id FROM membership m JOIN login_identity li ON li.user_id = m.user_id"
" WHERE li.provider = 'phone' AND li.external_user_id = :v)",
{"v": phone_lookup_key(q)},
)
elif q.upper().startswith("KT-"):
where, params = (
"WHERE t.id IN (SELECT tenant_id FROM activation_request WHERE request_no = :v)",
{"v": q.upper()},
)
elif q:
where, params = "WHERE t.name ILIKE :v", {"v": f"%{q}%"}
else:
where, params = "", {}
rows = (await s.execute(text(TENANT_ROW + where + " ORDER BY t.created_at DESC LIMIT 50"), params)).all()
return [_tenant(r) for r in rows]
async def tenant_detail(tenant_id: UUID, ops_id: UUID) -> dict[str, Any]:
async with ops_tx() as s:
head = (await s.execute(text(TENANT_ROW + "WHERE t.id = :t"), {"t": tenant_id})).first()
if head is None:
raise AppError("not_found")
p = {"t": tenant_id}
members = (
await s.execute(
text(
"SELECT u.display_name, m.role, m.status, li.contact_hint, li.last_login_at FROM membership m"
" JOIN user_account u ON u.id = m.user_id"
" LEFT JOIN login_identity li ON li.user_id = m.user_id AND li.provider = 'phone'"
" WHERE m.tenant_id = :t ORDER BY m.created_at"
),
p,
)
).all()
subs = (
await s.execute(
text(
"SELECT x.plan_code, x.status, x.source, x.starts_at, x.ends_at, r.request_no FROM subscription x"
" LEFT JOIN activation_request r ON r.id = x.activation_request_id"
" WHERE x.tenant_id = :t ORDER BY x.starts_at DESC"
),
p,
)
).all()
entries = (
await s.execute(
text(
"SELECT e.kind, e.amount, e.reason, e.ref_type, e.created_at, a.unit,"
" e.actor_ops_id IS NOT NULL AS by_ops FROM quota_entry e"
" JOIN quota_account a ON a.id = e.account_id WHERE e.tenant_id = :t ORDER BY e.id DESC LIMIT 30"
),
p,
)
).all()
jobs = (
await s.execute(
text(
"SELECT 'parse_job' AS kind, status, count(*) FROM parse_job WHERE tenant_id = :t GROUP BY status"
" UNION ALL SELECT 'check_run', status, count(*) FROM check_run WHERE tenant_id = :t"
" GROUP BY status"
" UNION ALL SELECT 'report_job', status, count(*) FROM report_job WHERE tenant_id = :t"
" GROUP BY status"
),
p,
)
).all()
s.add(
AuditEvent(
id=uuid7(),
tenant_id=tenant_id,
actor_kind="ops",
actor_ops_id=ops_id,
action="ops.tenant.view",
object_type="tenant",
object_id=tenant_id,
)
)
stats: dict[str, dict[str, int]] = {}
for kind, status, n in jobs:
stats.setdefault(kind, {})[status] = n
return {
**_tenant(head),
"members": [
{"display_name": m[0], "role": m[1], "status": m[2], "phone_hint": m[3], "last_login_at": m[4]}
for m in members
],
"subscriptions": [
{"plan_code": x[0], "status": x[1], "source": x[2], "starts_at": x[3], "ends_at": x[4], "request_no": x[5]}
for x in subs
],
"quota_entries": [
{
"kind": e[0],
"amount": e[1],
"reason": e[2],
"ref_type": e[3],
"created_at": e[4],
"unit": e[5],
"by_ops": e[6],
}
for e in entries
],
"jobs": stats,
}
async def activation_requests(status: str | None) -> list[dict[str, Any]]:
async with ops_tx() as s:
rows = (
await s.execute(
text(
"SELECT r.id, r.request_no, r.tenant_id, t.name, r.plan_code, r.status, r.invoice_info, r.ops_note,"
" r.invoice_no, r.created_at, r.handled_at, li.contact_hint FROM activation_request r"
" JOIN tenant t ON t.id = r.tenant_id"
" LEFT JOIN login_identity li ON li.user_id = r.applicant_user_id AND li.provider = 'phone'"
" WHERE CAST(:st AS text) IS NULL OR r.status = :st"
" ORDER BY (r.status IN ('submitted', 'paid')) DESC, r.created_at DESC LIMIT 200"
),
{"st": status},
)
).all()
return [
{
"id": r.id,
"request_no": r.request_no,
"tenant": {"id": r.tenant_id, "name": r.name},
"plan_code": r.plan_code,
"status": r.status,
"invoice_info": r.invoice_info,
"ops_note": r.ops_note,
"invoice_no": r.invoice_no,
"applicant_phone_hint": r.contact_hint,
"created_at": r.created_at,
"handled_at": r.handled_at,
}
for r in rows
]
async def request_tenant(request_id: UUID) -> UUID:
async with ops_tx() as s:
t = (
await s.execute(text("SELECT tenant_id FROM activation_request WHERE id = :r"), {"r": request_id})
).scalar_one_or_none()
if t is None:
raise AppError("not_found")
return t
async def feedback(kind: str | None) -> list[dict[str, Any]]:
"""误报 / 漏报(J5):不显示招标 / 投标原文(M6-7),只给标题、理由、规则、提示词版本、页码。"""
async with ops_tx() as s:
rows = (
await s.execute(
text(
"SELECT f.id, f.kind, f.source, f.note, f.object_type, f.created_at, t.name AS tenant_name,"
" fi.title AS finding_title, fi.severity, fi.rule_code, fi.detector, fi.type AS finding_type,"
" ci.title AS item_title, cr.rule_set_version, cr.prompt_versions,"
" (SELECT array_agg(DISTINCT c.page_no ORDER BY c.page_no) FROM finding_citation fc"
" JOIN citation c ON c.id = fc.citation_id WHERE fc.finding_id = fi.id) AS pages"
" FROM feedback f JOIN tenant t ON t.id = f.tenant_id"
" LEFT JOIN finding fi ON f.object_type = 'finding' AND fi.id = f.object_id"
" LEFT JOIN checklist_item ci ON ci.id = coalesce(fi.checklist_item_id,"
" CASE WHEN f.object_type = 'checklist_item' THEN f.object_id END)"
" LEFT JOIN check_run cr ON cr.id = fi.check_run_id"
" WHERE CAST(:k AS text) IS NULL OR f.kind = :k ORDER BY f.created_at DESC LIMIT 200"
),
{"k": kind},
)
).all()
return [
{
"id": r.id,
"kind": r.kind,
"source": r.source,
"note": r.note,
"object_type": r.object_type,
"tenant_name": r.tenant_name,
"title": r.finding_title or r.item_title,
"severity": r.severity,
"rule_code": r.rule_code,
"detector": r.detector,
"finding_type": r.finding_type,
"rule_set_version": r.rule_set_version,
"prompt_versions": r.prompt_versions,
"pages": r.pages or [],
"created_at": r.created_at,
}
for r in rows
]
async def failed_jobs() -> list[dict[str, Any]]:
"""解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数;不含文档内容。"""
async with ops_tx() as s:
rows = (
await s.execute(
text(
"SELECT j.kind, j.id, j.tenant_id, t.name, j.stage, j.error_class, j.error_code, j.error_detail,"
" j.created_at, j.started_at, j.finished_at,"
" (SELECT count(*) FROM ai_call a WHERE a.job_id = j.id) AS ai_calls FROM ("
" SELECT 'parse_job' AS kind, id, tenant_id, stage, error_class, error_code, error_detail,"
" created_at, started_at, finished_at FROM parse_job WHERE status = 'failed'"
" UNION ALL SELECT 'check_run', id, tenant_id, stage, error_class, error_code, error_detail,"
" created_at, started_at, finished_at FROM check_run WHERE status = 'failed'"
" UNION ALL SELECT 'report_job', id, tenant_id, NULL, NULL, error_code, NULL,"
" created_at, created_at, finished_at FROM report_job WHERE status = 'failed'"
" ) j JOIN tenant t ON t.id = j.tenant_id ORDER BY j.created_at DESC LIMIT 200"
)
)
).all()
return [
{
"kind": r.kind,
"id": r.id,
"tenant": {"id": r.tenant_id, "name": r.name},
"stage": r.stage,
"error_class": r.error_class,
"error_code": r.error_code,
"error_detail": r.error_detail,
"created_at": r.created_at,
"duration_s": (r.finished_at - r.started_at).total_seconds() if r.finished_at and r.started_at else None,
"ai_calls": r.ai_calls,
}
for r in rows
]
async def audit_events(tenant_id: UUID | None, action: str | None) -> list[dict[str, Any]]:
"""审计:动作、对象、操作人、时间;修改前后的内容不给(业务内容)。"""
async with ops_tx() as s:
rows = (
await s.execute(
text(
"SELECT e.id, e.tenant_id, t.name, e.actor_kind, e.actor_name, e.action, e.object_type,"
" e.object_id, e.project_id, e.content_scrubbed_at, e.created_at, o.display_name AS ops_name"
" FROM audit_event e JOIN tenant t ON t.id = e.tenant_id"
" LEFT JOIN ops_admin o ON o.id = e.actor_ops_id"
" WHERE (CAST(:t AS uuid) IS NULL OR e.tenant_id = :t)"
" AND (CAST(:a AS text) IS NULL OR e.action LIKE :a || '%')"
" ORDER BY e.created_at DESC LIMIT 200"
),
{"t": tenant_id, "a": action},
)
).all()
return [
{
"id": r.id,
"tenant": {"id": r.tenant_id, "name": r.name},
"actor_kind": r.actor_kind,
"actor_name": r.ops_name if r.actor_kind == "ops" else r.actor_name,
"action": r.action,
"object_type": r.object_type,
"object_id": r.object_id,
"project_id": r.project_id,
"scrubbed": r.content_scrubbed_at is not None,
"created_at": r.created_at,
}
for r in rows
]
async def summary() -> dict[str, int]:
async with ops_tx() as s:
row = (
await s.execute(
text(
"WITH f AS (SELECT created_at FROM parse_job WHERE status = 'failed'"
" UNION ALL SELECT created_at FROM check_run WHERE status = 'failed'"
" UNION ALL SELECT created_at FROM report_job WHERE status = 'failed')"
" SELECT (SELECT count(*) FROM activation_request WHERE status IN ('submitted', 'paid')),"
" (SELECT count(*) FROM f WHERE created_at > now() - interval '7 days'),"
" (SELECT count(*) FROM feedback WHERE created_at > now() - interval '7 days')"
)
)
).one()
return {"open_requests": row[0], "failed_jobs_7d": row[1], "feedback_7d": row[2]}

View File

@@ -0,0 +1,222 @@
"""运营后台接口(/ops/v1,只挂在 ops 进程上,决策 M6-2)。跨企业的查询走 bd_ops;对某个企业的操作走它自己的租户事务,
每个操作都写审计(操作人是运营账号)。"""
from __future__ import annotations
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Request, Response
from pydantic import BaseModel, Field
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.core.config import get_settings
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.ops import auth, console
from bid_doctor.ops.auth import Op
router = APIRouter()
def _ctx(tenant_id: UUID, op: auth.Operator) -> TenantContext:
return TenantContext(tenant_id=tenant_id, user_id=UUID(int=0), actor_name=f"运营 · {op.display_name}")
class LoginIn(BaseModel):
username: str = Field(min_length=1, max_length=60)
password: str = Field(min_length=1, max_length=200)
totp: str = Field(pattern=r"^\d{6}$")
@router.post("/auth/login")
async def login(body: LoginIn, request: Request, response: Response) -> dict[str, Any]:
ip = request.client.host if request.client else ""
token, op = await auth.login(body.username, body.password, body.totp, ip)
auth.set_cookies(response, token)
return _me(op)
@router.post("/auth/logout", status_code=204)
async def logout(op: Op, response: Response) -> None:
await auth.logout(op)
auth.clear_cookies(response)
def _me(op: auth.Operator) -> dict[str, Any]:
return {
"id": op.id,
"username": op.username,
"display_name": op.display_name,
"role": op.role,
"role_label": auth.ROLE_LABEL.get(op.role, op.role),
"permissions": sorted(op.permissions),
"env": get_settings().env,
}
@router.get("/me")
async def me(op: Op) -> dict[str, Any]:
return _me(op)
@router.get("/summary")
async def summary(op: Op) -> dict[str, int]:
"""菜单上的待办数:没权限的那一项给 0(菜单本来也不显示)。"""
counts = await console.summary()
allowed = {
"open_requests": auth.OpsPermission.ACTIVATION_MANAGE,
"failed_jobs_7d": auth.OpsPermission.JOBS_VIEW,
"feedback_7d": auth.OpsPermission.FEEDBACK_VIEW,
}
return {k: (v if allowed[k] in op.permissions else 0) for k, v in counts.items()}
@router.get("/tenants")
async def tenants(op: Op, q: str = "") -> dict[str, Any]:
auth.require(op, auth.OpsPermission.TENANT_VIEW)
return {"items": await console.search_tenants(q)}
@router.get("/tenants/{tenant_id}")
async def tenant(tenant_id: UUID, op: Op) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.TENANT_VIEW)
return await console.tenant_detail(tenant_id, op.id)
class AdjustIn(BaseModel):
unit: Literal["parse"] = "parse"
amount: int = Field(ge=-10000, le=10000)
reason: str = Field(min_length=2, max_length=200)
@router.post("/tenants/{tenant_id}/quota-adjustments", status_code=201)
async def adjust(tenant_id: UUID, body: AdjustIn, op: Op) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.QUOTA_ADJUST)
ctx = _ctx(tenant_id, op)
async with tenant_tx(ctx) as s:
e = await billing.adjust(s, ctx, unit=body.unit, amount=body.amount, reason=body.reason, ops_id=op.id)
await audit.record(
s,
ctx,
"ops.quota.adjust",
"quota_entry",
e.id,
actor_kind="ops",
actor_ops_id=op.id,
after={"unit": body.unit, "amount": body.amount, "reason": body.reason},
)
return {"id": e.id, "amount": e.amount}
@router.get("/activation-requests")
async def requests(op: Op, status: str | None = None) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
return {"items": await console.activation_requests(status)}
class ActivateIn(BaseModel):
months: int = Field(default=billing.PERIOD_MONTHS, ge=1, le=36)
class RejectIn(BaseModel):
note: str = Field(min_length=2, max_length=500)
class InvoiceIn(BaseModel):
invoice_no: str = Field(min_length=2, max_length=60)
async def _on_request(request_id: UUID, op: auth.Operator) -> TenantContext:
"""申请所属企业的租户事务里做:开通、退回、到账、开票。"""
return _ctx(await console.request_tenant(request_id), op)
@router.post("/activation-requests/{request_id}/paid")
async def mark_paid(request_id: UUID, op: Op) -> dict[str, str]:
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
ctx = await _on_request(request_id, op)
async with tenant_tx(ctx) as s:
await billing.mark_paid(s, ctx, request_id, ops_id=op.id)
await audit.record(
s, ctx, "ops.activation.paid", "activation_request", request_id, actor_kind="ops", actor_ops_id=op.id
)
return {"status": "paid"}
@router.post("/activation-requests/{request_id}/activate")
async def activate(request_id: UUID, body: ActivateIn, op: Op) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
ctx = await _on_request(request_id, op)
async with tenant_tx(ctx) as s:
sub = await billing.activate(s, ctx, request_id, ops_id=op.id, months=body.months)
await audit.record(
s,
ctx,
"ops.activation.activate",
"activation_request",
request_id,
actor_kind="ops",
actor_ops_id=op.id,
after={"months": body.months, "starts_at": sub.starts_at.isoformat(), "subscription_status": sub.status},
)
return {
"status": "activated",
"subscription": {"status": sub.status, "starts_at": sub.starts_at, "ends_at": sub.ends_at},
}
@router.post("/activation-requests/{request_id}/reject")
async def reject(request_id: UUID, body: RejectIn, op: Op) -> dict[str, str]:
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
ctx = await _on_request(request_id, op)
async with tenant_tx(ctx) as s:
await billing.reject(s, ctx, request_id, ops_id=op.id, note=body.note)
await audit.record(
s,
ctx,
"ops.activation.reject",
"activation_request",
request_id,
actor_kind="ops",
actor_ops_id=op.id,
after={"note": body.note},
)
return {"status": "rejected"}
@router.post("/activation-requests/{request_id}/invoice")
async def invoice(request_id: UUID, body: InvoiceIn, op: Op) -> dict[str, str]:
auth.require(op, auth.OpsPermission.INVOICE_MANAGE)
ctx = await _on_request(request_id, op)
async with tenant_tx(ctx) as s:
await billing.set_invoice(s, ctx, request_id, body.invoice_no)
await audit.record(
s,
ctx,
"ops.activation.invoice",
"activation_request",
request_id,
actor_kind="ops",
actor_ops_id=op.id,
after={"invoice_no": body.invoice_no},
)
return {"invoice_no": body.invoice_no}
@router.get("/feedback")
async def feedback(op: Op, kind: Literal["false_positive", "false_negative"] | None = None) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.FEEDBACK_VIEW)
return {"items": await console.feedback(kind)}
@router.get("/failed-jobs")
async def failed_jobs(op: Op) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.JOBS_VIEW)
return {"items": await console.failed_jobs()}
@router.get("/audit-events")
async def audit_events(op: Op, tenant_id: UUID | None = None, action: str | None = None) -> dict[str, Any]:
auth.require(op, auth.OpsPermission.AUDIT_VIEW)
return {"items": await console.audit_events(tenant_id, action)}

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from sqlalchemy import FetchedValue
from sqlalchemy import FetchedValue, LargeBinary
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, DbTs, Ts, UuidCol
@@ -25,3 +26,29 @@ class Feedback(Base):
created_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
updated_at: Mapped[DbTs]
class OpsAdmin(Base):
"""运营账号(全局表,只有 bd_ops 能读写,决策 M6-4)。"""
__tablename__ = "ops_admin"
id: Mapped[PK]
username: Mapped[str]
display_name: Mapped[str]
secret_hash: Mapped[str]
role: Mapped[str] = mapped_column(server_default=FetchedValue())
totp_cipher: Mapped[bytes] = mapped_column(LargeBinary)
status: Mapped[str] = mapped_column(server_default=FetchedValue())
created_at: Mapped[DbTs]
updated_at: Mapped[DbTs]
class OpsSession(Base):
__tablename__ = "ops_session"
id: Mapped[PK]
admin_id: Mapped[UuidCol]
token_hash: Mapped[bytes] = mapped_column(LargeBinary)
ip: Mapped[str] = mapped_column(INET)
created_at: Mapped[DbTs]
expires_at: Mapped[Ts]
revoked_at: Mapped[Ts | None]

View File

@@ -76,6 +76,12 @@ async def get_project(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
return await workbench.detail(s, ctx, project_id)
@router.delete("/projects/{project_id}", status_code=204)
async def delete_project(project_id: UUID, ctx: Ctx) -> None:
async with tenant_tx(ctx) as s:
await service.delete(s, ctx, project_id)
@router.post("/projects/{project_id}/transitions")
async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:

View File

@@ -5,11 +5,12 @@ from __future__ import annotations
from typing import Any
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.core import keys, queue
from bid_doctor.core.authz import Permission, require
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
@@ -52,6 +53,45 @@ async def get(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project:
return p
async def delete(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
"""删除项目(J4、G10,决策 M6-1):彻底删除,不能恢复。同一个事务里:
写一条不含业务内容的审计 → 抹掉这个项目审计里的内容 → 删项目(下面的文件、清单、检查、报告级联删除)→ 销毁数据密钥。
对象存储里的文件由后台任务清理(密钥已销毁,清理前也读不出来)。额度流水保留(不加外键)。
正在解析、检查、生成报告时不删:先取消(预扣退回),否则任务会往已删除的项目里写。"""
require(ctx, Permission.PROJECT_DELETE)
p = await get(s, ctx, project_id)
running = (
await s.execute(
text(
"SELECT 1 FROM parse_job WHERE project_id = :p AND status IN ('queued', 'running')"
" UNION ALL SELECT 1 FROM check_run WHERE project_id = :p AND status IN ('queued', 'running')"
" UNION ALL SELECT 1 FROM report_job WHERE project_id = :p AND status IN ('queued', 'running')"
" LIMIT 1"
),
{"p": p.id},
)
).first()
if running:
raise AppError("job_already_running", "项目有正在进行的解析、检查或报告,先取消或等它完成再删除")
await remove(s, ctx, p)
async def remove(s: AsyncSession, ctx: TenantContext, p: Project, *, actor_kind: str = "user") -> None:
"""删除一个项目的业务内容(delete 和注销账号共用)。"""
await s.execute(
text(
"UPDATE audit_event SET before = NULL, after = NULL, content_scrubbed_at = now()"
" WHERE project_id = :p AND content_scrubbed_at IS NULL AND (before IS NOT NULL OR after IS NOT NULL)"
),
{"p": p.id},
)
await audit.record(s, ctx, "project.delete", "project", p.id, project_id=p.id, actor_kind=actor_kind)
await s.delete(p)
await s.flush()
await keys.destroy_project_key(s, ctx, p.id)
await queue.enqueue(s, "purge_project_files", {"tenant_id": str(ctx.tenant_id), "project_id": str(p.id)})
def ensure_editable(p: Project) -> None:
if p.status in ("submitted", "archived"):
raise AppError("project_read_only")

View File

@@ -6,6 +6,7 @@ worker 直连数据库(Procrastinate 靠 LISTEN/NOTIFY 发现新任务,不
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
import procrastinate
@@ -42,6 +43,30 @@ async def generate_report(tenant_id: str, job_id: str) -> None:
await runner.run(UUID(tenant_id), UUID(job_id))
@app.task(name="purge_project_files", retry=3)
async def purge_project_files(tenant_id: str, project_id: str) -> None:
"""删除项目以后清对象存储(data-model §6.1)。密钥已经销毁,清不掉也读不出来;失败重试几次。"""
from bid_doctor.core.storage import project_prefix, storage
storage().delete_prefix(project_prefix(tenant_id, project_id))
@app.task(name="close_tenant", retry=3)
async def close_tenant(tenant_id: str) -> None:
from bid_doctor.identity import closure
await closure.finish(UUID(tenant_id))
@app.periodic(cron="5 * * * *", periodic_id="billing_tick")
@app.task(name="billing_tick", retry=False, queueing_lock="billing_tick")
async def billing_tick(timestamp: int) -> None:
"""每小时一次:到期清零、排队的续费生效、到期前 30 / 7 / 0 天提醒(决策 M6-5)。每个企业一个事务。"""
from bid_doctor.billing import tasks
await tasks.tick(datetime.fromtimestamp(timestamp, UTC))
async def run_worker() -> None:
async with app.open_async():
await app.run_worker_async(concurrency=4, install_signal_handlers=True)

View File

@@ -19,6 +19,7 @@ os.environ.update(
"BD_ENV": "test",
"BD_DATABASE_URL": f"postgresql+psycopg://bd_app:dev-app@{PG}/bid_doctor_test",
"BD_MIGRATE_DATABASE_URL": f"postgresql+psycopg://bd_owner:dev-owner@{PG}/bid_doctor_test",
"BD_OPS_DATABASE_URL": f"postgresql+psycopg://bd_ops:dev-ops@{PG}/bid_doctor_test",
"BD_VALKEY_URL": os.environ.get("BD_TEST_VALKEY", "redis://127.0.0.1:63799/1"),
"BD_STORAGE_DIR": tempfile.mkdtemp(prefix="bd-test-objects-"),
"BD_AI_PROVIDER": "fake",
@@ -38,7 +39,7 @@ def _recreate_database() -> None:
with psycopg.connect(f"postgresql://bd_owner:dev-owner@{PG}/postgres", autocommit=True) as conn:
conn.execute("DROP DATABASE IF EXISTS bid_doctor_test WITH (FORCE)")
conn.execute("CREATE DATABASE bid_doctor_test")
conn.execute("GRANT CONNECT ON DATABASE bid_doctor_test TO bd_app")
conn.execute("GRANT CONNECT ON DATABASE bid_doctor_test TO bd_app, bd_ops")
@pytest.fixture(scope="session", autouse=True)

View File

@@ -0,0 +1,205 @@
"""M6 剩下的 P0(m6-plan):额度流水、80% / 100% 提醒、开通和续费、到期只读、删除项目、注销账号、通知已读。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
import httpx
import pytest
from sqlalchemy import text
from bid_doctor.billing import service as billing
from bid_doctor.billing import tasks
from bid_doctor.core.db import TenantContext, tenant_tx
from tests.conftest import User, balance, project_with_tender, register, run_parse
OPS = uuid4() # 运营后台还没做:直接调 service,记一个运营编号
INVOICE = {
"title": "测试集成公司",
"tax_no": "91320100MA1XXXXX0Y",
"invoice_type": "general",
"contact_name": "张工",
"contact_phone": "13800000000",
}
def ctx_of(u: User) -> TenantContext:
return TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0), actor_name="系统")
async def activate(u: User, request_id: str) -> None:
async with tenant_tx(ctx_of(u)) as s:
await billing.activate(s, ctx_of(u), UUID(request_id), ops_id=OPS)
async def entries(u: User, **params: Any) -> list[dict[str, Any]]:
r = await u.get("/quota/entries", params=params)
assert r.status_code == 200, r.text
return r.json()["items"]
async def test_ledger_alerts_and_pages(user: User) -> None:
"""流水:任务能对上项目和用途,余额逐条可核;用到 80%、100% 各提醒一次(免费版 3 次)。"""
project_id, doc_id = await project_with_tender(user)
assert (await run_parse(user, doc_id))["status"] == "succeeded"
rows = await entries(user)
assert [(r["kind"], r["amount"], r["balance_after"]) for r in rows] == [
("settle", 0, 2),
("hold", -1, 2),
("grant", 3, 3),
]
hold = rows[1]
assert hold["project"] == {"id": project_id, "name": hold["project"]["name"], "deleted": False}
assert hold["ref"]["type"] == "parse_job" and hold["ref"]["label"] == "招标文件"
assert rows[0]["hold_entry_id"] == hold["id"] and rows[2]["reason"] == "注册赠送"
# 分页:每页 1 条,接着上一页往后
first = (await user.get("/quota/entries", params={"limit": 1})).json()
second = (await user.get("/quota/entries", params={"limit": 1, "cursor": first["next_cursor"]})).json()
assert first["items"][0]["kind"] == "settle" and second["items"][0]["kind"] == "hold"
# 再解析两次:第 3 次时余额 0,80% 和 100% 一起提醒(每档只一次)
for _ in range(2):
r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
from bid_doctor.document.pipeline import runner
await runner.run(UUID(user.tenant_id), UUID(r.json()["id"]))
assert await balance(user) == 0
kinds = [n["kind"] for n in (await user.get("/notifications")).json()["items"]]
assert kinds.count("quota_80") == 1 and kinds.count("quota_100") == 1
q = (await user.get("/quota")).json()
assert q["usage"]["tender"] == 3 and q["accounts"][0]["period_used"] == 3
async def test_activation_renewal_expiry_read_only(app_client: httpx.AsyncClient) -> None:
u = await register(app_client, "开通测试公司")
bad = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "tax_no": "123"}})
assert bad.status_code == 422
special = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "invoice_type": "special"}})
assert special.status_code == 422 and special.json()["code"] == "validation_failed"
r = await u.post("/activation-requests", json={"invoice_info": INVOICE})
assert r.status_code == 201, r.text
req = r.json()
assert req["status"] == "submitted" and req["request_no"].startswith("KT-") and req["payment"]["payee_name"]
assert (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()["code"] == "activation_pending"
assert (await u.get("/quota")).json()["pending_request"]["id"] == req["id"]
# 运营开通:免费版剩下的 3 次清零,发放专业版 500 次
await activate(u, req["id"])
me = (await u.get("/me")).json()
assert me["plan"]["code"] == "pro" and me["plan"]["read_only"] is False
rows = await entries(u)
assert [(x["kind"], x["amount"]) for x in rows[:2]] == [("grant", 500), ("expire", -3)]
assert await balance(u) == 500
ends = datetime.fromisoformat(me["plan"]["ends_at"])
notes = [n["kind"] for n in (await u.get("/notifications")).json()["items"]]
assert "subscription_activated" in notes
# 续费:当前一期还没到期,新一期排队,从旧一期结束时开始
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
await activate(u, r2["id"])
q = (await u.get("/quota")).json()
assert q["renewal"] and datetime.fromisoformat(q["renewal"]["starts_at"]) == ends and await balance(u) == 500
# 到期前 30 天、7 天提醒各一次;重复跑不重复提醒(续过费的不提醒,先把排队的一期撤掉)
async with tenant_tx(ctx_of(u)) as s:
await s.execute(text("UPDATE subscription SET status = 'cancelled' WHERE status = 'scheduled'"))
for days in (25, 25, 5):
await tasks.tick(ends - timedelta(days=days))
reminders = [n for n in (await u.get("/notifications")).json()["items"] if n["kind"] == "subscription_expiring"]
assert [n["title"] for n in reminders] == ["专业版还有 5 天到期", "专业版还有 25 天到期"]
# 到期:余额清零,只读——新建项目被拒,开通申请、通知已读照常
await tasks.tick(ends + timedelta(minutes=1))
me = (await u.get("/me")).json()
assert me["plan"]["read_only"] is True and await balance(u) == 0
assert (await u.post("/projects", json={})).json()["code"] == "plan_expired"
assert (await u.post("/notifications/read", json={})).status_code == 204
assert (await u.get("/notifications", params={"unread": True})).json()["unread_count"] == 0
r3 = await u.post("/activation-requests", json={"invoice_info": INVOICE})
assert r3.status_code == 201
await activate(u, r3.json()["id"]) # 过期后续费:马上开始新一期
assert (await u.get("/me")).json()["plan"]["read_only"] is False and await balance(u) == 500
async def test_delete_project(user: User) -> None:
project_id, doc_id = await project_with_tender(user)
queued = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
assert queued.status_code == 201
blocked = await user.delete(f"/projects/{project_id}")
assert blocked.status_code == 409 and blocked.json()["code"] == "job_already_running"
assert (await user.post(f"/parse-jobs/{queued.json()['id']}/cancel")).status_code in (200, 202)
from bid_doctor.document.pipeline import runner
await runner.run(UUID(user.tenant_id), UUID(queued.json()["id"])) # 取消:预扣退回
assert (await run_parse(user, doc_id))["status"] == "succeeded"
r = await user.delete(f"/projects/{project_id}")
assert r.status_code == 204, r.text
assert (await user.get(f"/projects/{project_id}")).status_code == 404
# 流水还在,项目标「已删除」;没有净扣点的任务(取消的那次)照样对得上
rows = [x for x in await entries(user) if x["project"]]
assert rows and all(x["project"]["deleted"] and x["project"]["name"] is None for x in rows)
assert sum(x["amount"] for x in rows) == -1
async with tenant_tx(ctx_of(user)) as s:
key = (await s.execute(text("SELECT wrapped_key FROM data_key WHERE project_id = :p"), {"p": project_id})).one()
audit = (
await s.execute(
text(
"SELECT count(*) FILTER (WHERE before IS NOT NULL OR after IS NOT NULL), count(*)"
" FROM audit_event WHERE project_id = :p"
),
{"p": project_id},
)
).one()
from bid_doctor.core import keys
from bid_doctor.core.errors import AppError
with pytest.raises(AppError) as err: # 删除后不再建新密钥(还在跑的任务写不回文件)
await keys.data_key(s, ctx_of(user), UUID(project_id))
assert err.value.code == "not_found"
assert key[0] is None and audit[0] == 0 and audit[1] >= 2
async def test_close_tenant(app_client: httpx.AsyncClient) -> None:
u = await register(app_client, "注销测试公司")
project_id, doc_id = await project_with_tender(u)
assert (await run_parse(u, doc_id))["status"] == "succeeded"
from bid_doctor.core.crypto import phone_lookup_key
from bid_doctor.core.valkey import valkey
await valkey().delete(f"sms:rate:{phone_lookup_key(u.phone)}") # 注册时刚发过一条,60 秒内不能再发
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code == 202
wrong = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "别的公司"})
assert wrong.status_code == 422 and wrong.json()["code"] == "tenant_name_mismatch"
bad_code = await u.post("/tenant/close", json={"sms_code": "111111", "confirm_tenant_name": "注销测试公司"})
assert bad_code.json()["code"] == "sms_code_invalid"
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code in (202, 429)
r = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "注销测试公司"})
assert r.status_code == 202, r.text
assert (await u.get("/me")).status_code == 401
from bid_doctor.identity import closure
await closure.finish(UUID(u.tenant_id))
await closure.finish(UUID(u.tenant_id)) # 重复执行没事
async with tenant_tx(ctx_of(u)) as s:
left = (
await s.execute(
text(
"SELECT (SELECT count(*) FROM project),"
" (SELECT count(*) FROM data_key WHERE wrapped_key IS NOT NULL),"
" (SELECT count(*) FROM quota_entry), (SELECT status FROM tenant),"
" (SELECT count(*) FROM audit_event WHERE actor_name IS NOT NULL OR after IS NOT NULL)"
)
)
).one()
assert left[0] == 0 and left[1] == 0 and left[2] > 0 and left[3] == "closed" and left[4] == 0
# 手机号可以重新注册(新企业)
assert (await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "register"})).status_code in (
202,
429,
)
del project_id

View File

@@ -0,0 +1,181 @@
"""运营后台(FR-M6-09,m6-plan §2,决策 M6-2 / M6-4 / M6-7):独立进程、用户名 + 密码 + TOTP、IP 白名单;
跨企业只读走 bd_ops,对某个企业的操作走它自己的租户事务并写审计;不显示业务内容。"""
from __future__ import annotations
import base64
import itertools
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from bid_doctor.core.crypto import totp
from bid_doctor.core.db import TenantContext, ops_tx, tenant_tx
from tests.conftest import User, project_with_tender, register, run_parse
from tests.test_billing_flow import INVOICE, entries
_names = itertools.count(1)
async def ops_client(ip: str = "127.0.0.1") -> httpx.AsyncClient:
from bid_doctor.ops.app import create_ops_app
transport = httpx.ASGITransport(app=create_ops_app(), client=(ip, 51000))
return httpx.AsyncClient(transport=transport, base_url="http://ops/ops/v1")
async def new_admin() -> tuple[str, bytes]:
from bid_doctor.ops.auth import create_admin
username = f"ops{next(_names)}"
uri = await create_admin(username, "运营小王", "correct horse battery")
b32 = parse_qs(urlsplit(uri).query)["secret"][0]
return username, base64.b32decode(b32 + "=" * (-len(b32) % 8))
@pytest.fixture
async def ops() -> AsyncIterator[httpx.AsyncClient]:
username, secret = await new_admin()
async with await ops_client() as c:
r = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
assert r.status_code == 200, r.text
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
yield c
async def test_login_needs_password_totp_and_allowed_ip() -> None:
username, secret = await new_admin()
async with await ops_client() as c:
bad = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": "000000"}
)
assert bad.status_code == 401 and bad.json()["code"] == "ops_login_failed"
assert (await c.get("/me")).status_code == 401
async with await ops_client("10.1.2.3") as far:
denied = await far.post("/auth/login", json={"username": username, "password": "x", "totp": totp(secret)})
assert denied.status_code == 403 and denied.json()["code"] == "ops_ip_denied"
# 用户端的 bd_app 读不到运营账号表
async with tenant_tx(
TenantContext(tenant_id=__import__("uuid").uuid4(), user_id=__import__("uuid").UUID(int=0))
) as s:
with pytest.raises(ProgrammingError):
await s.execute(text("SELECT 1 FROM ops_admin"))
async def test_tenants_activation_and_adjust(ops: httpx.AsyncClient, app_client: httpx.AsyncClient) -> None:
u: User = await register(app_client, "运营测试甲公司")
project_id, doc_id = await project_with_tender(u)
assert (await run_parse(u, doc_id))["status"] == "succeeded"
found = (await ops.get("/tenants", params={"q": "运营测试甲"})).json()["items"]
assert [t["name"] for t in found] == ["运营测试甲公司"] and found[0]["balance"] == 2
assert (await ops.get("/tenants", params={"q": u.phone})).json()["items"][0]["id"] == u.tenant_id
detail = (await ops.get(f"/tenants/{u.tenant_id}")).json()
assert detail["projects"] == 1 and detail["jobs"]["parse_job"] == {"succeeded": 1}
assert "未命名项目" not in str(detail) and project_id not in str(detail) # 不显示项目名称和编号
# 开通:用户提交 → 运营标记到账 → 开通 → 登记发票;全程写审计(操作人是运营)
req = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
listed = (await ops.get("/activation-requests", params={"status": "submitted"})).json()["items"]
assert req["id"] in [x["id"] for x in listed]
assert (await ops.get("/tenants", params={"q": req["request_no"]})).json()["items"][0]["id"] == u.tenant_id
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 409
assert (await ops.post(f"/activation-requests/{req['id']}/paid")).status_code == 200
r = await ops.post(f"/activation-requests/{req['id']}/activate", json={})
assert r.status_code == 200 and r.json()["subscription"]["status"] == "active", r.text
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 200
assert (await u.get("/me")).json()["plan"]["code"] == "pro"
# 再申请一份,运营退回(要写原因),用户看得到原因
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
assert (await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": ""})).status_code == 422
assert (
await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": "抬头和转账单位不一致"})
).status_code == 200
mine = (await u.get("/activation-requests")).json()["items"]
assert mine[0]["status"] == "rejected" and mine[0]["ops_note"] == "抬头和转账单位不一致"
# 调额度:原因必填;不能扣成负数;流水里看得到
assert (
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": ""})
).status_code == 422
too_much = await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": -9999, "reason": "测试扣减"})
assert too_much.status_code == 422
assert (
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": "试用补偿"})
).status_code == 201
adj = (await entries(u))[0]
assert (adj["kind"], adj["amount"], adj["reason"]) == ("adjust", 20, "试用补偿")
events = (await ops.get("/audit-events", params={"tenant_id": u.tenant_id, "action": "ops."})).json()["items"]
actions = {e["action"] for e in events}
assert {
"ops.tenant.view",
"ops.activation.paid",
"ops.activation.activate",
"ops.activation.reject",
"ops.activation.invoice",
"ops.quota.adjust",
} <= actions
assert all(e["actor_kind"] == "ops" and e["actor_name"] == "运营小王" for e in events)
async def test_feedback_and_failed_jobs_without_content(ops: httpx.AsyncClient, user: User) -> None:
from tests.test_check_flow import checked_project
project_id, _, run = await checked_project(user)
finding = (await user.get(f"/check-runs/{run['id']}/findings")).json()["items"][0]
r = await user.post(
"/feedback",
json={
"project_id": project_id,
"object_type": "finding",
"object_id": finding["id"],
"kind": "false_positive",
"note": "这不是问题(误报)",
},
)
assert r.status_code == 201
items = (await ops.get("/feedback", params={"kind": "false_positive"})).json()["items"]
mine = next(x for x in items if x["note"] == "这不是问题(误报)")
assert mine["title"] == finding["title"] and mine["rule_set_version"] and mine["pages"]
assert "quote" not in str(mine) and "text" not in mine # 不带原文
async with ops_tx() as s: # bd_ops 只读:改不了业务表
with pytest.raises(ProgrammingError):
await s.execute(text("UPDATE project SET name = 'x'"))
assert isinstance((await ops.get("/failed-jobs")).json()["items"], list)
async def test_every_endpoint_checks_permission() -> None:
"""角色只留结构:现在只有管理员(全部权限);没定义的角色什么权限都没有,接口一律 403,/me 照常给出权限点。"""
username, secret = await new_admin()
async with ops_tx() as s:
await s.execute(text("UPDATE ops_admin SET role = 'sales' WHERE username = :u"), {"u": username})
async with await ops_client() as c:
r = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
assert r.status_code == 200 and r.json()["permissions"] == [] and r.json()["role"] == "sales"
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
for path in ("/tenants", "/activation-requests", "/feedback", "/failed-jobs", "/audit-events"):
assert (await c.get(path)).status_code == 403, path
assert (await c.get("/summary")).json() == {"open_requests": 0, "failed_jobs_7d": 0, "feedback_7d": 0}
async with await ops_client() as c: # 管理员
username, secret = await new_admin()
me = (
await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
).json()
assert (
me["role"] == "admin"
and me["role_label"] == "管理员"
and "audit.view" in me["permissions"]
and me["env"] == "test"
)