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

@@ -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"
)