feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 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:
@@ -101,6 +101,7 @@
|
||||
- 只有最近一次检查的问题可以处理,历史批次返回 409 `run_superseded`;
|
||||
- 批量处理只限「提示」级,混进了别的级别就整批拒绝,返回不符合的问题编号。
|
||||
- **已递交、已归档的项目只读**:所有修改接口都返回 409 `project_read_only`。撤销递交或撤销归档之后才能改(FR-M6-02)。
|
||||
- **套餐到期后整个账号只读**(决策 M6-5):写操作返回 409 `plan_expired`;退出、续费申请、删除项目、注销账号、通知已读、反馈照常放行(在鉴权依赖里统一判断)。
|
||||
|
||||
### 4.6 不在接口里出现的东西
|
||||
|
||||
@@ -136,7 +137,7 @@
|
||||
| 403 | `csrf_failed` `forbidden` | CSRF 头不对;没有这个权限点 |
|
||||
| 404 | `not_found` `phone_not_registered` | |
|
||||
| 409 | `concurrent_session` | 已有其他设备在线(API3) |
|
||||
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` | 额度、计费、套餐限制 |
|
||||
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` `plan_expired` `activation_pending` | 额度、计费、套餐限制和到期 |
|
||||
| 409 | `job_already_running` `job_finished` | 任务状态冲突 |
|
||||
| 409 | `project_read_only` `invalid_transition` `run_superseded` `item_not_pending` `document_in_use` 等 | 业务状态不允许这个操作 |
|
||||
| 412 | `precondition_failed` | If-Match 版本不一致 |
|
||||
@@ -186,14 +187,14 @@
|
||||
|
||||
| 模块 | 已实现 | 还没实现 |
|
||||
|---|---|---|
|
||||
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 |
|
||||
| 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 |
|
||||
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包 | 删除 |
|
||||
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me`、注销账号(`POST /me/sms-codes` + `POST /tenant/close`,m6-plan) | 修改显示名、在线会话列表和下线 |
|
||||
| 额度与套餐 | `GET /plans`、`GET /quota`(含按功能分布、排队的续费、待处理的申请)、额度流水、开通申请(m6-plan) | — |
|
||||
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包、删除(m6-plan) | — |
|
||||
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
|
||||
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本、「已被补遗 N 更新」的确认 | — |
|
||||
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
|
||||
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
|
||||
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |
|
||||
| 通知与事件 | 事件流(含检查进度)、通知列表(标题、链接、项目名称、分页)、标为已读、反馈 | — |
|
||||
|
||||
读标修改类接口的实现约定(2026-11-03):
|
||||
|
||||
|
||||
114
docs/architecture/m6-plan.md
Normal file
114
docs/architecture/m6-plan.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# M6 剩下的 P0:额度流水、套餐开通、数据删除、运营后台(实现计划 v0.1)
|
||||
|
||||
依据:FR-M6-03 / 05 / 06 / 07 / 09(`docs/analysis/03-functional-requirements.md`),J4、J5(`02-user-journeys.md`),G6、G8、G10,决策 Q9、Q13、P1、DM4;表结构已在基线迁移里(`data-model.md` §4、§6),用户端接口已在 `openapi.yaml` 里定义;运营后台的接口和选型「做到时再定」(架构 §3.11、§4.3)。
|
||||
|
||||
## 0. 现状
|
||||
|
||||
| 方面 | 已有 | 还没有 |
|
||||
|---|---|---|
|
||||
| 额度 | 注册发放、预扣 / 结算 / 退回 / 免扣、`GET /quota`、顶栏额度(≥ 80% 赭色) | 流水接口和额度页、80% / 100% 提醒、人工调整、过期 |
|
||||
| 套餐 | 免费版注册即开通、`GET /plans`、专业版套餐(解析 500,D2 待定) | 开通 / 续费申请、运营开通、到期处理 |
|
||||
| 删除 | 资质删除(QL6) | 删除项目、注销账号 |
|
||||
| 通知 | 解析、检查、报告完成写通知;事件流 | 前端通知入口、标为已读 |
|
||||
| 运营后台 | 表:`ops_admin`、`ops_session`;角色 `bd_ops`(文档里有,部署脚本里还没建);反馈写入 | 进程、登录、接口、前端,全部没有 |
|
||||
|
||||
另外发现一处冲突:原型 `project.html` 的删除写「30 天内可以在回收站恢复」,而 G10、DM4(已确认)是删除时销毁项目数据密钥、彻底删除。按决策优先,见 M6-1。
|
||||
|
||||
## 1. 用户端
|
||||
|
||||
### 1.1 额度页(FR-M6-03,原型 `quota.html`)
|
||||
|
||||
- 侧栏账户区(头像、套餐标签)点开进「额度与套餐」页;顶栏额度改成链接,也进这个页。
|
||||
- 页面:本期额度卡(剩余 / 已用 / 退回,期间起止)→ 扣点规则表(原型原样)→ 按功能分布(读标招标文件、补遗和澄清、查标、免扣次数)→ 本期流水。
|
||||
- 流水:`GET /quota/entries`,按单位筛选、按时间倒序分页;每行写时间、类型(发放 / 预扣 / 结算 / 退回 / 免扣 / 过期 / 调整)、数量(+ / −,墨色,Q1)、余额、关联项目 · 任务(「招标文件」「第 2 次检查」;项目删了写「已删除的项目」和任务编号,G10)、说明(调整原因、失败原因、「距上次扣额度的检查不到 24 小时」)。导出 CSV 在前端生成。
|
||||
- 提醒:预扣后余额跨过 80%、100%(按本期发放算)时写站内通知,`quota_alert` 保证每期每档只提醒一次。
|
||||
|
||||
### 1.2 开通与续费(FR-M6-05,G8)
|
||||
|
||||
- 免费版:额度页和顶栏「额度用完」提示里有「开通专业版」;专业版:到期前 30 天起显示「续费」。
|
||||
- 申请对话框(原型):企业名称(注册时的,只读)、发票类型(普票 / 专票)、纳税人识别号、联系人、手机号;专票再加地址电话、开户行账号。`POST /activation-requests`,不扣额度、不用先付款。
|
||||
- 提交后页面顶部显示申请单号、状态(等待对公转账 / 已到账 / 已开通 / 已退回)、收款信息(收款单位、开户行、账号,从配置读 `BD_PAYEE_*`)和「转账备注写申请单号」。
|
||||
- 运营确认到账并开通:建专业版订阅、发放额度(流水「发放」,说明写申请单号),发站内通知;之后填发票号。
|
||||
- 续费:当前专业版还没到期时,新一期从旧一期结束时开始;旧一期剩下的额度到期清零(「本期没用完的不结转」,原型)。
|
||||
|
||||
### 1.3 到期(FR-M6-05)
|
||||
|
||||
- worker 每天跑一次定时任务(Procrastinate 周期任务):到期的订阅改「已过期」、记「过期」流水把余额清零;排队的下一期到点发放。
|
||||
- 提前 30 天、7 天、到期当天各发一次站内通知。
|
||||
- 过期后:数据保留,只读(M6-5)。
|
||||
|
||||
### 1.4 删除项目(J4、G10、data-model §6.1)
|
||||
|
||||
- 入口:项目头「更多」菜单(和归档放一起),已递交、已归档的也能删。
|
||||
- 确认框:写明删掉什么、保留什么(额度流水、不含业务内容的审计),输入项目名称确认(原型)。
|
||||
- `DELETE /projects/{id}`:同一事务里写审计、抹掉这个项目审计的内容、删项目行(级联)、销毁数据密钥;提交后后台任务删对象存储前缀。正在解析 / 检查的项目先取消任务再删(预扣退回)。
|
||||
|
||||
### 1.5 账号页与注销(J4、data-model §6.2)
|
||||
|
||||
- 「额度与套餐」页旁边一个「账号」页:企业名称、手机号(脱敏)、显示名、套餐;最下面「注销账号」。
|
||||
- 注销:确认框写明后果(全部项目、资质库删除;开通申请和发票记录按财务要求保留;剩余额度作废、不退款),输入企业名称 + 短信验证码。`POST /tenant/close`:立即吊销会话、企业标「已注销」,项目按 §1.4 由后台任务逐个删。
|
||||
|
||||
### 1.6 通知入口
|
||||
|
||||
- 顶栏加铃铛(原型有),有未读时带圆点;点开是浮层列表(最近 30 条:解析 / 检查 / 报告完成、额度提醒、开通结果、到期提醒),点一条跳到相关页面并标为已读,「全部标为已读」。`POST /notifications/read`。
|
||||
|
||||
## 2. 运营后台(FR-M6-09)
|
||||
|
||||
### 2.1 形态
|
||||
|
||||
- **后端**:同一个 Python 包的第三种进程 `bid-doctor ops`(和 api、worker 一样的镜像),单独端口、路径前缀 `/ops/v1`,用 `bd_ops` 数据库角色(绕过行级安全策略);用户端 api 进程里不挂任何运营接口。部署时运营端口只对内网 / 白名单开放。
|
||||
- **登录**:用户名 + 密码 + TOTP 二次验证;IP 白名单(`BD_OPS_ALLOWED_IPS`,空着时拒绝启动于生产环境);会话 8 小时;账号用命令行建(`bid-doctor ops-admin create`),没有自助注册。
|
||||
- **前端**:`apps/admin`,Vue 3 + Vite,复用 `tokens.css` 和组件样式,不引入带样式的框架(M6-3);单独构建、单独部署,私有化不交付。
|
||||
- **审计**:每个运营操作(包括查询企业详情)都写审计事件,操作人记运营账号。
|
||||
|
||||
### 2.2 页面和接口(最小版)
|
||||
|
||||
| 页面 | 做什么 | 接口(`/ops/v1`) |
|
||||
|---|---|---|
|
||||
| 企业 | 按企业名称、手机号(用 HMAC 查)、申请单号搜;详情:成员(手机号脱敏)、套餐和到期日、各单位余额、项目数、最近任务;**不显示项目名称和任何业务内容** | `GET /tenants`、`GET /tenants/{id}` |
|
||||
| 开通申请 | 待处理在前;标记「已到账」→「开通」(选套餐和周期,默认专业版 1 年),或「退回」写原因;开通后填发票号 | `GET /activation-requests`、`POST .../{id}/activate`、`/reject`、`/invoice` |
|
||||
| 额度调整 | 在企业详情里:单位、±数量、原因(必填) | `POST /tenants/{id}/quota-adjustments` |
|
||||
| 反馈 | 误报 / 漏报列表:类型、用户写的理由、问题标题和级别、规则编号、提示词版本、出处页码,**不显示招标 / 投标文件原文**(M6-7) | `GET /feedback` |
|
||||
| 失败任务 | 解析、检查、报告失败的:错误类别、错误码、阶段、耗时、模型调用次数,不含文档内容 | `GET /failed-jobs` |
|
||||
| 审计 | 按企业 / 对象 / 时间查审计事件(项目删除后只剩类型和时间) | `GET /audit-events` |
|
||||
|
||||
## 3. 步骤
|
||||
|
||||
1. 用户端后端:流水、提醒、开通申请、到期任务、删除项目、注销、通知已读,各配集成测试(额度对账:任何失败任务都没有净扣点;删除后对象不可解密)。
|
||||
2. 用户端前端:额度与套餐页、账号页、通知入口、删除项目。
|
||||
3. 运营后台后端:`ops` 进程、`bd_ops` 角色(部署脚本)、登录和 TOTP、上表接口、审计。
|
||||
4. 运营后台前端:`apps/admin` 六个页面。
|
||||
5. 浏览器走查:注册 → 用完免费额度 → 申请开通 → 运营开通 → 流水核对 → 到期(改时钟)→ 删除项目 → 注销。
|
||||
|
||||
## 4. 待确认(建议见每条)
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|---|---|
|
||||
| M6-1 | 删除项目要不要「30 天回收站」(原型这么写) | **不要,立即彻底删除**:按已确认的 G10、DM4 销毁数据密钥;回收站要软删除,和「彻底删除」冲突(同 QL6)。确认框要求输入项目名称,写明不能恢复。原型文案改掉 |
|
||||
| M6-2 | 运营接口放在哪 | **单独的第三种进程 `bid-doctor ops`**(同一镜像、单独端口、`bd_ops` 角色);用户端 api 进程里没有运营接口,绕过行级安全策略的代码不会被公网请求触到 |
|
||||
| M6-3 | 运营后台前端选型 | **Vue 3 + 复用 tokens.css / 组件样式,不引入带样式框架**:页面只有六个、以表格和表单为主;和用户端同一套组件,维护成本低。另一种做法是引入 Element Plus 之类加快开发,但多一套视觉和依赖 |
|
||||
| M6-4 | 运营登录 | **用户名 + 密码 + TOTP + IP 白名单**,账号命令行创建(架构 §3.11 已写独立登录、白名单、二次验证) |
|
||||
| M6-5 | 套餐到期后怎么办 | **不自动降为免费版**;项目只读(能看、能下载已导出的报告、能删除;不能新建项目、解析、检查、改清单);额度清零;提前 30 天、7 天、当天提醒。续费到账后恢复 |
|
||||
| M6-6 | 开通申请页写不写价格、收款信息从哪来 | **页面不写价格**(D2 额度数字和定价都还没定),写「运营会联系你确认金额」;收款单位 / 开户行 / 账号从配置读,没配时显示「(待定)」,生产环境没配拒绝启动 |
|
||||
| M6-7 | 运营看反馈时能不能看原文 | **不看原文**:只看标题、理由、规则编号、提示词版本、页码(红线 10:数据敏感、按租户隔离)。要复盘原文时请用户导出报告发过来,或以后做「用户授权查看」 |
|
||||
| M6-8 | 注销账号的确认方式 | **输入企业名称 + 短信验证码**;有没到期的专业版时写明剩余额度作废、不退款 |
|
||||
|
||||
## 5. 落地记录(2026-11-05)
|
||||
|
||||
M6-1–M6-8 全部按建议确认后实现。和上文不一样、或计划里没写到的:
|
||||
|
||||
- **迁移 0010**:订阅加「排队中」(scheduled),续费到账时当前一期还没到期就排队,到点由定时任务生效发放;开通申请加申请单号 `request_no`(KT- 加 6 位);通知加「已到期」「申请被退回」;`billing_due(now)` 是 bd_auth 所有的 SECURITY DEFINER 函数,定时任务(worker 每小时 `billing_tick`)用它跨企业找到期、该生效、该提醒的订阅,只拿编号,处理时回到各企业自己的租户事务。到期时还有没了结的预扣就等下一次再清零,避免退回的 1 次漏在余额里。
|
||||
- **迁移 0011**:`bd_ops` 的权限(跨企业只读、运营账号和会话表可写、审计可写),`bd_app` 收回运营账号表的权限。角色本身由部署脚本建(`init-roles.sh`,CI 同一个脚本)。
|
||||
- **只读的落点**:到期后写操作在鉴权依赖里统一拦(`identity/deps.py` 的 `READ_ONLY_ALLOWED`),退出、续费申请、删除项目、注销、通知已读、反馈、管理登录设备照常放行;不用逐个接口去改。
|
||||
- **额度单位**:只发放现在用得上的「解析」;专业版套餐里的 `generate`(M4 标书生成)等功能上线再发,免得顶栏出现用不了的额度。
|
||||
- **删除项目**:正在解析、检查、生成报告时返回 409 `job_already_running`,先取消(预扣退回)再删;`data_key` 销毁以后不再建新密钥,还在跑的任务写不回文件。审计的 `before` / `after` 改为 Python 的 None 存成 SQL NULL(以前是 JSON 的 null)。
|
||||
- **注销账号**:验证码改为登录后 `POST /me/sms-codes` 发到自己的号码(前端不知道完整号码);同步部分完成后手机号就能重新注册,删项目、资质库、抹审计内容、销毁密钥、清对象存储由后台任务 `close_tenant` 做,可以重复执行。
|
||||
- **uuid7 单调**:同一进程内严格递增(RFC 9562 方法 2),额度流水按编号排序算逐条余额。
|
||||
- **顺手修的**:取消解析、取消检查的接口在刷新以前转 API 会触发懒加载报错(MissingGreenlet),取消按钮会 500。
|
||||
- **运营后台**:接口说明见 `ops-api.md`;前端 `apps/admin`(`pnpm dev:admin`,:5174)六个页面:登录、开通申请、企业(含调额度)、误报 / 漏报、失败任务、审计。
|
||||
|
||||
测试:后端 156 个(新增 `test_billing_flow.py` 4 个、`test_ops_console.py` 3 个,覆盖流水对账、提醒只发一次、开通 / 续费 / 到期只读、删除后不可解密、注销后可重新注册、运营登录和 IP 白名单、`bd_app` 读不到运营表、`bd_ops` 改不了业务表)。浏览器走查:注册 → 申请开通 → 运营标记到账、开通、登记发票 → 流水核对 → 调额度 → 到期只读 → 删除项目 → 注销账号。
|
||||
|
||||
没做的:运营后台没有「撤回申请」「延长订阅」这类操作(需要时用退回 + 重新申请、调额度代替);收款信息和发票是线下流程,系统只登记发票号。
|
||||
|
||||
**运营后台第二版(2026-11-05,决策 M6-9、M6-10)**:菜单改到左侧(墨色渐变底,标准横版 logo 反白,非生产环境挂赭色环境标签),分组可以展开收起、每项带图标和待办数(`GET /summary`),底部是当前运营人员(姓名、角色)和退出;登录页加 logo。运营人员的角色只留结构:迁移 0012 加 `ops_admin.role`,权限点和「角色 → 权限点」对应表、每个接口的检查点、按权限点过滤的菜单都已就位,现在只有管理员。见 `ops-api.md`。
|
||||
@@ -203,12 +203,40 @@ paths:
|
||||
'204': { description: 已吊销 }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/me/sms-codes:
|
||||
post:
|
||||
tags: [me]
|
||||
operationId: sendMySmsCode
|
||||
summary: 登录后发验证码到自己的手机号(注销账号的二次确认,前端不需要完整号码)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [purpose]
|
||||
properties:
|
||||
purpose: { type: string, enum: [close_tenant] }
|
||||
responses:
|
||||
'202':
|
||||
description: 已发送
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [resend_after_s]
|
||||
properties:
|
||||
resend_after_s: { type: integer }
|
||||
'429': { $ref: '#/components/responses/RateLimited' }
|
||||
|
||||
/tenant/close:
|
||||
post:
|
||||
tags: [me]
|
||||
operationId: closeTenant
|
||||
summary: 注销账号(删除企业下全部业务数据,不可恢复)
|
||||
description: data-model §6.2。需要短信验证码和企业名称两次确认。成功后当前会话失效。
|
||||
description: |
|
||||
data-model §6.2,决策 M6-8。需要短信验证码(`POST /me/sms-codes`)和企业名称两次确认;
|
||||
企业名称不一致返回 422 `tenant_name_mismatch`。成功后所有会话失效,手机号可以重新注册;项目、资质库等由后台删除。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -2168,7 +2196,7 @@ components:
|
||||
description: 给用户看的检查计费说明,如「检查从解析额度扣 1 次;同一项目 24 小时内复查不扣」
|
||||
QuotaOverview:
|
||||
type: object
|
||||
required: [plan, accounts]
|
||||
required: [plan, accounts, usage]
|
||||
properties:
|
||||
plan: { $ref: '#/components/schemas/PlanSummary' }
|
||||
accounts:
|
||||
@@ -2180,26 +2208,44 @@ components:
|
||||
unit: { $ref: '#/components/schemas/QuotaUnit' }
|
||||
balance: { type: integer, description: 剩余,含正在预扣的 }
|
||||
held: { type: integer, description: 正在预扣、还没结算的 }
|
||||
period_granted: { type: integer }
|
||||
period_used: { type: integer }
|
||||
period_granted: { type: integer, description: 本期发放(按当前这一期订阅) }
|
||||
period_used: { type: integer, description: 本期净用量(预扣 − 退回) }
|
||||
period_released: { type: integer, description: 本期退回的次数(失败、超时、取消) }
|
||||
used_ratio: { type: number, description: '0~1;≥ 0.8 时界面变赭色(Q9)' }
|
||||
by_feature:
|
||||
type: array
|
||||
description: 按功能分布,如解析、检查
|
||||
items:
|
||||
type: object
|
||||
required: [feature, used]
|
||||
properties:
|
||||
feature: { type: string, enum: [parse, check] }
|
||||
used: { type: integer }
|
||||
usage:
|
||||
type: object
|
||||
description: 本期按功能分布(没被退回的预扣);deleted 是项目已删除、看不出用途的;waived 是 24 小时内复查的免扣次数
|
||||
required: [tender, addendum, check, deleted, waived]
|
||||
properties:
|
||||
tender: { type: integer }
|
||||
addendum: { type: integer, description: 补遗和澄清 }
|
||||
check: { type: integer }
|
||||
deleted: { type: integer }
|
||||
waived: { type: integer }
|
||||
renewal:
|
||||
description: 已续费、排队等当前一期结束后生效的那一期
|
||||
oneOf:
|
||||
- type: 'null'
|
||||
- type: object
|
||||
required: [starts_at, ends_at]
|
||||
properties:
|
||||
starts_at: { $ref: '#/components/schemas/Timestamp' }
|
||||
ends_at: { $ref: '#/components/schemas/Timestamp' }
|
||||
pending_request:
|
||||
description: 还在等转账或开通的申请
|
||||
oneOf:
|
||||
- type: 'null'
|
||||
- $ref: '#/components/schemas/ActivationRequest'
|
||||
QuotaEntry:
|
||||
type: object
|
||||
required: [id, unit, kind, amount, created_at]
|
||||
required: [id, unit, kind, amount, balance_after, created_at]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Id' }
|
||||
unit: { $ref: '#/components/schemas/QuotaUnit' }
|
||||
kind: { $ref: '#/components/schemas/QuotaEntryKind' }
|
||||
amount: { type: integer, description: 对余额的影响;结算、免扣为 0 }
|
||||
balance_after: { type: integer, description: 记完这一条以后的账户余额 }
|
||||
hold_entry_id: { oneOf: [{ $ref: '#/components/schemas/Id' }, { type: 'null' }], description: 结算、退回对应的预扣 }
|
||||
reason: { type: string, description: '调整原因;免扣时为规则说明,如「24 小时内复查」' }
|
||||
project:
|
||||
type: [object, 'null']
|
||||
@@ -2222,7 +2268,8 @@ components:
|
||||
invoice_info: { $ref: '#/components/schemas/InvoiceInfo' }
|
||||
InvoiceInfo:
|
||||
type: object
|
||||
required: [title, tax_no]
|
||||
required: [title, tax_no, contact_name, contact_phone]
|
||||
description: 专票(invoice_type = special)还要地址电话和开户行账号
|
||||
properties:
|
||||
title: { type: string, description: 发票抬头 }
|
||||
tax_no: { type: string, description: 纳税人识别号 }
|
||||
@@ -2233,14 +2280,27 @@ components:
|
||||
contact_phone: { type: string }
|
||||
ActivationRequest:
|
||||
type: object
|
||||
required: [id, plan_code, status, created_at]
|
||||
required: [id, request_no, plan_code, status, payment, created_at]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Id' }
|
||||
request_no: { type: string, examples: [KT-7Q3M2X], description: 申请单号,对公转账时写在备注里 }
|
||||
plan_code: { $ref: '#/components/schemas/PlanCode' }
|
||||
status: { type: string, enum: [submitted, paid, activated, rejected, cancelled] }
|
||||
status:
|
||||
type: string
|
||||
enum: [submitted, paid, activated, rejected, cancelled]
|
||||
description: 等待转账 / 已到账 / 已开通 / 已退回 / 已撤回
|
||||
invoice_info: { $ref: '#/components/schemas/InvoiceInfo' }
|
||||
payment_instructions: { type: string, description: 对公转账账户和备注要求 }
|
||||
ops_note: { type: string }
|
||||
payment:
|
||||
type: object
|
||||
description: 收款信息(配置项;没配时为「(待定)」,决策 M6-6)。页面不写价格,运营联系确认金额
|
||||
required: [payee_name, payee_bank, payee_account]
|
||||
properties:
|
||||
payee_name: { type: string }
|
||||
payee_bank: { type: string }
|
||||
payee_account: { type: string }
|
||||
ops_note: { type: [string, 'null'], description: 退回原因 }
|
||||
invoice_no: { type: [string, 'null'] }
|
||||
handled_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] }
|
||||
created_at: { $ref: '#/components/schemas/Timestamp' }
|
||||
|
||||
# ---------- projects
|
||||
@@ -3180,13 +3240,14 @@ components:
|
||||
kind:
|
||||
type: string
|
||||
enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, report_succeeded, report_failed, quota_80, quota_100,
|
||||
subscription_activated, subscription_expiring]
|
||||
subscription_activated, subscription_expiring, subscription_expired, activation_rejected]
|
||||
title: { type: string }
|
||||
project_id:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Id'
|
||||
- type: 'null'
|
||||
link: { type: [string, 'null'], description: '前端路由,如 /projects/<id>/results' }
|
||||
project_name: { type: [string, 'null'] }
|
||||
link: { type: [string, 'null'], description: '前端路由,如 /projects/<id>/results、/quota' }
|
||||
read_at:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Timestamp'
|
||||
|
||||
65
docs/architecture/ops-api.md
Normal file
65
docs/architecture/ops-api.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 运营后台接口(/ops/v1)
|
||||
|
||||
运营后台的接口不在用户端契约 `openapi.yaml` 里:它们只挂在 `bid-doctor ops` 进程上(决策 M6-2),前端 `apps/admin` 是唯一的调用方,类型在 `apps/admin/src/api.ts` 里手写。实现见 `server/src/bid_doctor/ops/`(`console_api.py` 路由、`console.py` 查询、`auth.py` 登录)。
|
||||
|
||||
## 安全
|
||||
|
||||
| 方面 | 做法 |
|
||||
|---|---|
|
||||
| 进程 | 和 api、worker 同一镜像的第三种进程,单独端口(开发 8019);部署时只对内网开放 |
|
||||
| 来源地址 | `BD_OPS_ALLOWED_IPS`(CIDR 列表)以外的请求一律 403 `ops_ip_denied`;生产环境不配或放开全部地址拒绝启动 |
|
||||
| 登录 | 用户名 + 密码(scrypt)+ TOTP 6 位动态码(身份验证器 App);同一用户名 15 分钟最多试 5 次;账号只能用 `bid-doctor ops-admin create` 建 |
|
||||
| 会话 | cookie `bd_ops_session`(HttpOnly、SameSite=Strict,8 小时)+ 写操作带 `X-CSRF-Token`(等于 cookie `bd_ops_csrf`) |
|
||||
| 数据库 | 跨企业查询用 `bd_ops`(绕过行级安全策略,业务表只读,运营账号和会话表可写,审计可写);对某个企业的修改走 `bd_app` + 那个企业的租户事务。`bd_app` 读不到运营账号表 |
|
||||
| 留痕 | 每个操作都写审计(`actor_kind = ops`,记运营账号),查看企业详情也记一条 `ops.tenant.view` |
|
||||
| 内容 | 不显示项目名称、招标 / 投标原文、审计的修改前后(决策 M6-7、红线 10) |
|
||||
|
||||
## 角色和权限点(只留结构,决策 M6-10)
|
||||
|
||||
以后会有管理员、运营、销售、运维等不同权限的运营人员。现在只建结构:
|
||||
|
||||
- `ops_admin.role`(迁移 0012,不加 CHECK);
|
||||
- 权限点 `OpsPermission`(`ops/auth.py`):`tenant.view`、`quota.adjust`、`activation.manage`、`invoice.manage`、`feedback.view`、`jobs.view`、`audit.view`;
|
||||
- 「角色 → 权限点」对应表 `ROLE_PERMISSIONS`:现在只有 `admin`(全部权限)。表里有、对应表里没有的角色什么权限都没有;
|
||||
- 每个接口开头都调 `auth.require(op, 权限点)`,没有权限返回 403 `forbidden`;`/me` 返回角色和权限点,前端菜单按权限点过滤(`apps/admin/src/nav.ts`),一组都看不到时整组不显示。
|
||||
|
||||
加角色时只改 `ROLE_PERMISSIONS`、`ROLE_LABEL` 和 `ops-admin create --role` 的说明;要不要有「运营账号管理」页面、谁能建账号,到时再定。
|
||||
|
||||
## 菜单(决策 M6-9)
|
||||
|
||||
左侧菜单由 `apps/admin/src/nav.ts` 一处配置:分组(客户、质量、安全)→ 菜单项(图标、路由、要的权限点、待办数)。分组可以展开收起,记在本机;跳到收起分组里的页面时自动展开。待办数来自 `GET /summary`(待处理的开通申请、近 7 天失败任务、近 7 天反馈),做完操作和每分钟刷新一次。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/auth/login` | `{username, password, totp}`;失败 401 `ops_login_failed` |
|
||||
| POST | `/auth/logout` | |
|
||||
| GET | `/me` | 当前运营账号:角色、权限点、运行环境(非生产环境时菜单顶部提示) |
|
||||
| GET | `/summary` | 菜单上的待办数;没有权限的那一项给 0 |
|
||||
| GET | `/tenants?q=` | 企业名称(模糊)、手机号(精确,按 HMAC 查)、申请单号;不带条件列最近注册的 50 个 |
|
||||
| GET | `/tenants/{id}` | 成员(手机号脱敏)、套餐历史、最近 30 条额度流水(不带项目名称)、任务按状态计数 |
|
||||
| POST | `/tenants/{id}/quota-adjustments` | `{unit: parse, amount, reason}`;原因必填,不能扣成负数(G6) |
|
||||
| GET | `/activation-requests?status=` | 待处理(等待转账、已到账)在前 |
|
||||
| POST | `/activation-requests/{id}/paid` | 标记到账(只限等待转账) |
|
||||
| POST | `/activation-requests/{id}/activate` | `{months = 12}`;免费版 / 已过期马上开始新一期,专业版没到期时新一期排队(续费) |
|
||||
| POST | `/activation-requests/{id}/reject` | `{note}`,用户在额度页看得到原因 |
|
||||
| POST | `/activation-requests/{id}/invoice` | `{invoice_no}`,开通以后才能登记 |
|
||||
| GET | `/feedback?kind=` | 误报 / 漏报:标题、级别、理由、规则编号、规则集和提示词版本、页码 |
|
||||
| GET | `/failed-jobs` | 解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数 |
|
||||
| GET | `/audit-events?tenant_id=&action=` | 动作、对象、操作人、时间;`action` 按前缀筛选 |
|
||||
|
||||
## 本地跑起来
|
||||
|
||||
```
|
||||
cd server && ../.venv/bin/bid-doctor ops # :8019
|
||||
../.venv/bin/bid-doctor ops-admin create --username dev --display-name 开发运营 # 打印 otpauth:// 地址,用身份验证器 App 添加
|
||||
pnpm dev:admin # :5174,/ops 代理到 :8019
|
||||
```
|
||||
|
||||
已有的数据卷是在加 `bd_ops` 之前初始化的,要手工建一次角色(新数据卷由 `deploy/compose/postgres/init-roles.sh` 建):
|
||||
|
||||
```
|
||||
docker exec -e PGPASSWORD=dev-owner bid-doctor-postgres-1 psql -U bd_owner -d bid_doctor \
|
||||
-c "CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD 'dev-ops'" -c "GRANT CONNECT ON DATABASE bid_doctor TO bd_ops"
|
||||
```
|
||||
Reference in New Issue
Block a user