diff --git a/.env.example b/.env.example index 6485b33..97a17b8 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,12 @@ # BD_AI_MODEL=deepseek-chat # 按能力选模型(选填,JSON):没配的能力用 BD_AI_MODEL # BD_AI_MODELS={"reasoning": "deepseek-reasoner"} + +# 专业版对公转账的收款信息(决策 M6-6):开通申请里给用户看;没配时显示「(待定)」,prod 下必须配 +# BD_PAYEE_NAME= +# BD_PAYEE_BANK= +# BD_PAYEE_ACCOUNT= + +# 运营后台(决策 M6-2、M6-4):bid-doctor ops 进程用 bd_ops 做跨企业只读;只放行白名单地址(CIDR,JSON 数组) +# BD_OPS_DATABASE_URL=postgresql+psycopg://bd_ops:dev-ops@127.0.0.1:54329/bid_doctor +# BD_OPS_ALLOWED_IPS=["127.0.0.1/32", "10.0.0.0/8"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78e17ec..db57168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: POSTGRES_USER: bd_owner POSTGRES_DB: bid_doctor BD_APP_PASSWORD: dev-app + BD_OPS_PASSWORD: dev-ops run: sh deploy/compose/postgres/init-roles.sh - working-directory: server run: | diff --git a/AGENTS.md b/AGENTS.md index ae7e856..2b7e824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ 骨架见技术架构 §13。后端 Python 3.12+(uv workspace),前端 Vue 3 + Vite(pnpm workspace)。 ``` -server/ 后端:api 和 worker 是同一个包、两种进程(bid-doctor api | worker | migrate) +server/ 后端:api、worker、ops 是同一个包、三种进程(bid-doctor api | worker | ops | migrate | ops-admin) src/bid_doctor/ core/ 配置、数据库与租户事务、错误码、加密、对象存储、队列、事件流、权限点 identity/ billing/ project/ document/ checklist/ ai/ audit/ notification/ 业务模块(架构 §3.2) @@ -89,6 +89,7 @@ server/ 后端:api 和 worker 是同一个包、两种进程 migrations/ Alembic;sql/0001_baseline/ 是 data-model 的建表脚本,按模块拆分 tests/ 集成测试(真实 PostgreSQL + 行级安全策略)和单元测试;fixtures/ 是虚构的小样例 apps/web/ 用户端单页应用 +apps/admin/ 运营后台(单独构建、单独部署,私有化不交付;接口见 docs/architecture/ops-api.md) packages/api-client/ 由 docs/architecture/openapi.yaml 生成的类型 + 客户端(不手写接口类型) prompts/ 带版本号的提示词(已发布的版本不再修改) deploy/compose/ Docker Compose;deploy/docker/ 镜像 @@ -103,6 +104,7 @@ deploy/compose/ Docker Compose;deploy/docker/ 镜像 | 建表 / 升级数据库 | `cd server && ../.venv/bin/bid-doctor migrate` | | 起后端 | `cd server && ../.venv/bin/bid-doctor api`(:8009),另开一个终端 `../.venv/bin/bid-doctor worker` | | 起前端 | `pnpm dev`(:5173,`/api` 代理到 :8009)。dev 环境短信验证码固定是 `000000` | +| 起运营后台 | `cd server && ../.venv/bin/bid-doctor ops`(:8019,只放行白名单地址),`pnpm dev:admin`(:5174);运营账号 `../.venv/bin/bid-doctor ops-admin create --username <名> --display-name <姓名>`,打印的 otpauth 地址用身份验证器 App 添加 | | 后端检查 | `cd server && ../.venv/bin/ruff check src tests migrations && ../.venv/bin/ruff format --check src tests migrations && ../.venv/bin/pyright && ../.venv/bin/lint-imports && ../.venv/bin/pytest -q` | | 前端检查 | `pnpm typecheck && pnpm build` | | 改了 openapi.yaml 之后 | `pnpm gen:api`,重新生成的 `schema.d.ts` 一起提交(CI 会比对) | @@ -232,4 +234,10 @@ samples/ - 概览:加了检查记录卡和文件卡。 - 工作台:加了「致命」列、统计可点筛选、待处理的致命问题卡(「去处理」定位到那条问题)。 - 日期输入加了日历弹层;查标页加了 ? 键。 -- 下一步:M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 +- 2026-11-05:M6 剩下的 P0(`docs/architecture/m6-plan.md`,M6-1–M6-8 全部按建议确认)。 + - 用户端:额度与套餐页(流水逐条可核、按功能分布、扣点规则、导出 CSV)、开通 / 续费申请、80% / 100% 提醒、通知铃铛、账号页和注销、删除项目(彻底删除)。 + - 到期:专业版到期后整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒;worker 每小时跑一次定时任务。 + - 运营后台:独立进程 `bid-doctor ops` 和 `apps/admin`,用户名 + 密码 + TOTP + IP 白名单,可以处理开通申请、查企业、调额度、看误报 / 漏报、失败任务和审计,不看业务内容(`ops-api.md`)。新增数据库角色 `bd_ops`,迁移 0010、0011。 + - 顺手修了取消解析 / 检查时接口报 500 的问题。后端 156 个测试,浏览器从注册走到注销。 +- 2026-11-05:运营后台第二版(M6-9、M6-10):左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签),运营人员角色只留结构(`ops_admin.role`、权限点、每个接口的检查点、菜单按权限过滤,现在只有管理员),迁移 0012。 +- 下一步:可做的有:读标抽取专项优化(`extract-status.md`)、更多真实投标文件、M6 的 P1(协作权限、资产库)。 diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..bb9010e --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,13 @@ + + +
+ + + +最近 200 条。按动作前缀筛选,如 ops.(运营操作)、project.delete、tenant.close。
+{{ err }}
+| 时间 | 企业 | 操作人 | 动作 | 对象 | |
|---|---|---|---|---|---|
| {{ fmt(e.created_at) }} | +{{ ACTOR[e.actor_kind] ?? e.actor_kind }} · {{ e.actor_name }} | +{{ e.action }} | +{{ e.object_type }} {{ e.object_id?.slice(0, 8) ?? "" }} | +{{ e.scrubbed ? "内容已抹掉" : "" }} | +|
| 没有记录 | |||||
失败的任务都已自动退回额度。系统异常(infra)要排查,文件问题(input)一般是用户的文件读不了。
+{{ err }}
+| 时间 | 任务 | 企业 | 阶段 | 错误 | 耗时 | 模型调用 | 编号 |
|---|---|---|---|---|---|---|---|
| {{ fmt(j.created_at) }} | +{{ JOB_KIND[j.kind] ?? j.kind }} | +{{ j.stage ?? "—" }} | +
+ {{ CLASS[j.error_class ?? ""] ?? j.error_class ?? "" }} {{ j.error_code ?? "" }}
+ {{ j.error_detail }} + |
+ {{ j.duration_s != null ? `${Math.round(j.duration_s)} 秒` : "—" }} | +{{ j.ai_calls }} | +{{ j.id.slice(0, 8) }} | +|
| 没有失败的任务 | |||||||
只看标题、理由和规则,不看原文。这里的数字只给内部复盘用,界面上不对用户展示统计。
+{{ err }}
+| 时间 | 类型 | 企业 | 条目 | 用户写的 | 规则 / 版本 | 页码 |
|---|---|---|---|---|---|---|
| {{ fmt(f.created_at) }} | +{{ f.kind === "false_positive" ? "误报" : "漏报" }} {{ f.source }} |
+ {{ f.tenant_name }} | +
+ {{ SEV[f.severity] ?? f.severity }} {{ f.title ?? "(条目已删除)" }}
+ {{ f.object_type === "finding" ? "查标问题" : "读标清单项" }} · {{ f.finding_type }} · {{ f.detector === "ai" ? "模型" : "规则" }} + |
+ {{ f.note ?? "—" }} | +
+ {{ f.rule_code ?? "—" }} +{{ f.rule_set_version ?? "" }} · {{ Object.entries(f.prompt_versions).map(([k, v]) => `${k} ${v}`).join("、") }} + |
+ {{ f.pages.join("、") || "—" }} | +
| 还没有反馈 | ||||||
核对对公转账到账后再开通。转账备注应当是申请单号。
+{{ err }}
+| 申请单号 | 企业 | 发票信息 | 状态 | 提交时间 | 操作 |
|---|---|---|---|---|---|
| {{ r.request_no }} | +
+ 申请人 {{ r.applicant_phone_hint ?? "—" }} + |
+
+
+ {{ r.invoice_info.title }} · {{ INVOICE_TYPE[r.invoice_info.invoice_type ?? "general"] }} |
+
+ {{ REQUEST_STATUS[r.status] }}
+ 原因:{{ r.ops_note }} +发票 {{ r.invoice_no }} + |
+ {{ fmt(r.created_at) }} | +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
| {{ filter === "open" ? "没有待处理的申请" : "还没有申请" }} | |||||
{{ err }}
+ ++ {{ PLAN[t.plan.code ?? ""] ?? "—" }} · 至 {{ day(t.plan.ends_at) }}(已到期) + · 注册 {{ day(t.created_at) }} · 项目 {{ t.projects }} 个 · 已注销 +
+| 姓名 | 手机号 | 角色 | 最近登录 |
|---|---|---|---|
| {{ m.display_name }} | {{ m.phone_hint ?? "—" }} | {{ m.role === "owner" ? "所有者" : m.role }} | +{{ fmt(m.last_login_at) }} | +
{{ msg }}
++ {{ JOB_KIND[kind] ?? kind }}:{{ i ? " · " : "" }}{{ JOB_STATUS[s] ?? s }} {{ n }} +
+还没有任务
+| 套餐 | 状态 | 来源 | 开始 | 结束 | 申请单 |
|---|---|---|---|---|---|
| {{ PLAN[s.plan_code] ?? s.plan_code }} | {{ SUB[s.status] ?? s.status }} | +{{ s.source === "signup" ? "注册" : s.source === "manual" ? "运营开通" : s.source }} | +{{ day(s.starts_at) }} | {{ day(s.ends_at) }} | {{ s.request_no ?? "—" }} | +
| 时间 | 类型 | 数量 | 任务 | 说明 |
|---|---|---|---|---|
| {{ fmt(e.created_at) }} | +{{ QUOTA_KIND[e.kind] ?? e.kind }}(运营) | +{{ e.amount > 0 ? `+${e.amount}` : e.amount }} | +{{ e.ref_type ? JOB_KIND[e.ref_type] : "—" }} | +{{ e.reason === "recheck_24h" ? "24 小时内复查" : e.reason ?? "" }} | +
只看账号、套餐和用量,不看项目和文件内容。查看详情会留痕。
+{{ err }}
+| 企业 | 手机号 | 套餐 | 解析余额 | 项目 | 注册 | 状态 |
|---|---|---|---|---|---|---|
|
+ |
+ {{ t.phone_hint ?? "—" }} | ++ {{ PLAN[t.plan.code ?? ""] ?? "—" }} + 已到期 + 至 {{ day(t.plan.ends_at) }} + | +{{ t.balance ?? "—" }} | +{{ t.projects }} | +{{ day(t.created_at) }} | +{{ t.status === "closed" ? "已注销" : t.status === "active" ? "正常" : t.status }} | +
| 没有找到 | ||||||
还没有通知
+标记后项目改为只读,可以随时撤销。
还有 {{ p.readiness.open_fatal_count }} 项致命问题没有处理。是否递交由你决定,系统不会阻止。
+项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,不能恢复。需要留底的报告请先下载。
+额度流水和一条不含业务内容的操作记录会保留。
+一个企业一个账号;额度和数据都归属企业主体。
+注销后,企业下的全部项目(招标文件、投标文件、清单、检查结果、报告)和资质库都会彻底删除,不能恢复。需要留底的报告请先下载。
+开通申请和发票记录按财务要求保留;额度流水和不含业务内容的操作记录保留。注销后这个手机号可以重新注册。
+ +企业「{{ m?.tenant.name }}」下的全部项目和资质库都会彻底删除,不能恢复。
+专业版还没到期(到 {{ plan?.ends_at?.slice(0, 10) }}),剩余额度作废,不退款。
+{{ err.other }}
++ {{ plan.name }} · 本期 {{ day(plan.starts_at) }} 至 {{ day(plan.ends_at) }}。额度绑定企业主体,不可转让;本期没用完的不结转。 + {{ plan.name }} · {{ day(plan.starts_at) }} 注册起长期有效,额度不再补发。额度绑定企业主体,不可转让。 +
+专业版已于 {{ day(plan?.ends_at) }} 到期,数据只读:可以查看、下载已导出的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。
+专业版还有 {{ Math.max(daysLeft, 0) }} 天到期,到期后数据只读、额度清零。
+已续费:新一期 {{ day(q.renewal.starts_at) }} 至 {{ day(q.renewal.ends_at) }},到时自动生效并发放额度。
+金额由运营联系你确认。运营确认到账后开通,并开发票;开通后新一期额度会记进下面的流水。
+申请 {{ lastRejected.request_no }} 被退回:{{ lastRejected.ops_note }}。可以修改后重新提交。
++ 已用 {{ acct.period_used }} 次 · 退回 {{ acct.period_released ?? 0 }} 次 · 正在预扣 {{ acct.held }} 次 +
+ +读取中…
+| 动作 | 扣点 | 不扣点 |
|---|---|---|
| 解读一份招标文件、补遗或澄清 | +1 次解析:发起时预扣,成功后结算 | +解析失败、超时、取消:自动退回 | +
| 对一个项目做一次检查 | +专业版 1 次解析(投标文件的解析含在内);免费版:这个项目内不限次数 | +检查中断、系统异常:自动退回 同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起 |
+
| 时间 | 类型 | 数量 | 余额 | 关联项目 · 任务 | 说明 |
|---|---|---|---|---|---|
| {{ fmtDateTime(e.created_at) }} | +{{ KIND[e.kind] ?? e.kind }} | +{{ signed(e.amount) }} | +{{ e.balance_after }} | +{{ refText(e) || "—" }} | +{{ note(e) }} | +
| 还没有流水 | |||||
上传招标文件,标博士会逐条提取要求,每一条都带原文出处。
-项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 30 天内可以在回收站恢复,之后彻底清除。
+ body: `项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,不能恢复。需要留底的报告请先下载。
额度流水和一条不含业务内容的审计记录会保留。
`, ok: "删除项目", @@ -228,7 +228,7 @@ $("[data-state-acts]").addEventListener("click", (e) => { if (b.matches("[data-more]")) { e.stopPropagation(); const items = status === "已归档" ? [{ label: "删除项目", act: "delete" }] - : [{ label: "归档", sub: "不再出现在工作台的「进行中」里,可以撤销", act: "archive" }, "-", { label: "删除项目", sub: "30 天内可以在回收站恢复", act: "delete" }]; + : [{ label: "归档", sub: "不再出现在工作台的「进行中」里,可以撤销", act: "archive" }, "-", { label: "删除项目", sub: "彻底删除,不能恢复(决策 M6-1)", act: "delete" }]; App.floatMenu(b, items, (k) => act(k)); } }); diff --git a/package.json b/package.json index d9c6935..0817a83 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,14 @@ "name": "bid-doctor", "private": true, "packageManager": "pnpm@10.34.5", - "engines": { "node": ">=22" }, + "engines": { + "node": ">=22" + }, "scripts": { "dev": "pnpm --filter @bid-doctor/web dev", "build": "pnpm -r build", "typecheck": "pnpm -r typecheck", - "gen:api": "pnpm --filter @bid-doctor/api-client gen" + "gen:api": "pnpm --filter @bid-doctor/api-client gen", + "dev:admin": "pnpm --filter @bid-doctor/admin dev" } } diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts index 694cb5a..1e275ea 100644 --- a/packages/api-client/src/schema.d.ts +++ b/packages/api-client/src/schema.d.ts @@ -129,6 +129,23 @@ export interface paths { patch?: never; trace?: never; }; + "/me/sms-codes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 登录后发验证码到自己的手机号(注销账号的二次确认,前端不需要完整号码) */ + post: operations["sendMySmsCode"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tenant/close": { parameters: { query?: never; @@ -140,7 +157,9 @@ export interface paths { put?: never; /** * 注销账号(删除企业下全部业务数据,不可恢复) - * @description data-model §6.2。需要短信验证码和企业名称两次确认。成功后当前会话失效。 + * @description data-model §6.2,决策 M6-8。需要短信验证码(`POST /me/sms-codes`)和企业名称两次确认; + * 企业名称不一致返回 422 `tenant_name_mismatch`。成功后所有会话失效,手机号可以重新注册;项目、资质库等由后台删除。 + * */ post: operations["closeTenant"]; delete?: never; @@ -1572,17 +1591,31 @@ export interface components { balance: number; /** @description 正在预扣、还没结算的 */ held?: number; + /** @description 本期发放(按当前这一期订阅) */ period_granted: number; + /** @description 本期净用量(预扣 − 退回) */ period_used: number; + /** @description 本期退回的次数(失败、超时、取消) */ + period_released?: number; /** @description 0~1;≥ 0.8 时界面变赭色(Q9) */ used_ratio?: number; - /** @description 按功能分布,如解析、检查 */ - by_feature?: { - /** @enum {string} */ - feature: "parse" | "check"; - used: number; - }[]; }[]; + /** @description 本期按功能分布(没被退回的预扣);deleted 是项目已删除、看不出用途的;waived 是 24 小时内复查的免扣次数 */ + usage: { + tender: number; + /** @description 补遗和澄清 */ + addendum: number; + check: number; + deleted: number; + waived: number; + }; + /** @description 已续费、排队等当前一期结束后生效的那一期 */ + renewal?: null | { + starts_at: components["schemas"]["Timestamp"]; + ends_at: components["schemas"]["Timestamp"]; + }; + /** @description 还在等转账或开通的申请 */ + pending_request?: null | components["schemas"]["ActivationRequest"]; }; QuotaEntry: { id: components["schemas"]["Id"]; @@ -1590,6 +1623,10 @@ export interface components { kind: components["schemas"]["QuotaEntryKind"]; /** @description 对余额的影响;结算、免扣为 0 */ amount: number; + /** @description 记完这一条以后的账户余额 */ + balance_after: number; + /** @description 结算、退回对应的预扣 */ + hold_entry_id?: components["schemas"]["Id"] | null; /** @description 调整原因;免扣时为规则说明,如「24 小时内复查」 */ reason?: string; project?: { @@ -1612,6 +1649,7 @@ export interface components { plan_code: "pro"; invoice_info: components["schemas"]["InvoiceInfo"]; }; + /** @description 专票(invoice_type = special)还要地址电话和开户行账号 */ InvoiceInfo: { /** @description 发票抬头 */ title: string; @@ -1621,18 +1659,30 @@ export interface components { bank_account?: string; /** @enum {string} */ invoice_type?: "general" | "special"; - contact_name?: string; - contact_phone?: string; + contact_name: string; + contact_phone: string; }; ActivationRequest: { id: components["schemas"]["Id"]; + /** @description 申请单号,对公转账时写在备注里 */ + request_no: string; plan_code: components["schemas"]["PlanCode"]; - /** @enum {string} */ + /** + * @description 等待转账 / 已到账 / 已开通 / 已退回 / 已撤回 + * @enum {string} + */ status: "submitted" | "paid" | "activated" | "rejected" | "cancelled"; invoice_info?: components["schemas"]["InvoiceInfo"]; - /** @description 对公转账账户和备注要求 */ - payment_instructions?: string; - ops_note?: string; + /** @description 收款信息(配置项;没配时为「(待定)」,决策 M6-6)。页面不写价格,运营联系确认金额 */ + payment: { + payee_name: string; + payee_bank: string; + payee_account: string; + }; + /** @description 退回原因 */ + ops_note?: string | null; + invoice_no?: string | null; + handled_at?: components["schemas"]["Timestamp"] | null; created_at: components["schemas"]["Timestamp"]; }; /** @description 就绪状态只在 status = checked 时有意义(G7);未就绪也不阻止导出和递交(红线 4) */ @@ -2309,10 +2359,11 @@ export interface components { Notification: { id: components["schemas"]["Id"]; /** @enum {string} */ - kind: "parse_succeeded" | "parse_failed" | "check_succeeded" | "check_failed" | "report_succeeded" | "report_failed" | "quota_80" | "quota_100" | "subscription_activated" | "subscription_expiring"; + kind: "parse_succeeded" | "parse_failed" | "check_succeeded" | "check_failed" | "report_succeeded" | "report_failed" | "quota_80" | "quota_100" | "subscription_activated" | "subscription_expiring" | "subscription_expired" | "activation_rejected"; title: string; project_id?: components["schemas"]["Id"] | null; - /** @description 前端路由,如 /projects/