决策 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>
206 lines
10 KiB
Python
206 lines
10 KiB
Python
"""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
|