From dd8c04001d0237f535b095040e0055b49e93ab20 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 5 Nov 2026 15:06:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(m6):=20=E9=A2=9D=E5=BA=A6=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E3=80=81=E5=A5=97=E9=A4=90=E5=BC=80=E9=80=9A=E3=80=81?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=A0=E9=99=A4=E3=80=81=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=EF=BC=88M6=20=E5=89=A9=E4=B8=8B=E7=9A=84=20P?= =?UTF-8?q?0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决策 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 --- .env.example | 9 + .github/workflows/ci.yml | 1 + AGENTS.md | 12 +- apps/admin/index.html | 13 + apps/admin/package.json | 25 + apps/admin/src/App.vue | 53 ++ apps/admin/src/OpsSidebar.vue | 289 +++++++++ apps/admin/src/admin.css | 15 + apps/admin/src/api.ts | 155 +++++ apps/admin/src/main.ts | 11 + apps/admin/src/nav.ts | 49 ++ apps/admin/src/pages/AuditPage.vue | 50 ++ apps/admin/src/pages/FailedJobsPage.vue | 48 ++ apps/admin/src/pages/FeedbackPage.vue | 63 ++ apps/admin/src/pages/LoginPage.vue | 82 +++ apps/admin/src/pages/RequestsPage.vue | 126 ++++ apps/admin/src/pages/TenantPage.vue | 143 +++++ apps/admin/src/pages/TenantsPage.vue | 60 ++ apps/admin/src/router.ts | 20 + apps/admin/tsconfig.json | 20 + apps/admin/vite.config.ts | 25 + apps/web/src/components/ActivationDialog.vue | 192 ++++++ apps/web/src/components/AppShell.vue | 21 +- apps/web/src/components/NotificationBell.vue | 138 +++++ apps/web/src/components/ProjectHead.vue | 60 +- apps/web/src/lib/events.ts | 3 + apps/web/src/lib/keys.ts | 4 + apps/web/src/lib/queries.ts | 30 +- apps/web/src/pages/AccountPage.vue | 154 +++++ apps/web/src/pages/QuotaPage.vue | 287 +++++++++ apps/web/src/pages/WorkbenchPage.vue | 6 +- apps/web/src/router.ts | 2 + apps/web/src/styles/app.css | 7 + deploy/compose/compose.yaml | 16 + deploy/compose/postgres/init-roles.sh | 6 +- docs/architecture/api-design.md | 11 +- docs/architecture/m6-plan.md | 114 ++++ docs/architecture/openapi.yaml | 103 +++- docs/architecture/ops-api.md | 65 ++ docs/decisions.md | 17 + docs/prototype/project.html | 4 +- package.json | 7 +- packages/api-client/src/schema.d.ts | 111 +++- pnpm-lock.yaml | 31 + .../versions/0010_subscription_lifecycle.py | 66 ++ .../versions/0011_ops_role_grants.py | 37 ++ .../versions/0012_ops_admin_role.py | 22 + server/src/bid_doctor/audit/models.py | 13 +- server/src/bid_doctor/audit/service.py | 2 + server/src/bid_doctor/billing/api.py | 45 +- server/src/bid_doctor/billing/models.py | 34 + server/src/bid_doctor/billing/service.py | 583 +++++++++++++++++- server/src/bid_doctor/billing/tasks.py | 25 + server/src/bid_doctor/check/service.py | 1 + server/src/bid_doctor/cli.py | 24 + server/src/bid_doctor/core/config.py | 16 + server/src/bid_doctor/core/crypto.py | 40 ++ server/src/bid_doctor/core/db.py | 18 + server/src/bid_doctor/core/errors.py | 5 + server/src/bid_doctor/core/ids.py | 25 +- server/src/bid_doctor/core/keys.py | 22 + server/src/bid_doctor/core/storage.py | 4 + server/src/bid_doctor/document/service.py | 1 + server/src/bid_doctor/identity/api.py | 23 +- server/src/bid_doctor/identity/closure.py | 111 ++++ server/src/bid_doctor/identity/deps.py | 24 +- server/src/bid_doctor/notification/api.py | 88 ++- server/src/bid_doctor/ops/app.py | 52 ++ server/src/bid_doctor/ops/auth.py | 180 ++++++ server/src/bid_doctor/ops/console.py | 336 ++++++++++ server/src/bid_doctor/ops/console_api.py | 222 +++++++ server/src/bid_doctor/ops/models.py | 29 +- server/src/bid_doctor/project/api.py | 6 + server/src/bid_doctor/project/service.py | 42 +- server/src/bid_doctor/worker.py | 25 + server/tests/conftest.py | 3 +- server/tests/test_billing_flow.py | 205 ++++++ server/tests/test_ops_console.py | 181 ++++++ 78 files changed, 5064 insertions(+), 104 deletions(-) create mode 100644 apps/admin/index.html create mode 100644 apps/admin/package.json create mode 100644 apps/admin/src/App.vue create mode 100644 apps/admin/src/OpsSidebar.vue create mode 100644 apps/admin/src/admin.css create mode 100644 apps/admin/src/api.ts create mode 100644 apps/admin/src/main.ts create mode 100644 apps/admin/src/nav.ts create mode 100644 apps/admin/src/pages/AuditPage.vue create mode 100644 apps/admin/src/pages/FailedJobsPage.vue create mode 100644 apps/admin/src/pages/FeedbackPage.vue create mode 100644 apps/admin/src/pages/LoginPage.vue create mode 100644 apps/admin/src/pages/RequestsPage.vue create mode 100644 apps/admin/src/pages/TenantPage.vue create mode 100644 apps/admin/src/pages/TenantsPage.vue create mode 100644 apps/admin/src/router.ts create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/admin/vite.config.ts create mode 100644 apps/web/src/components/ActivationDialog.vue create mode 100644 apps/web/src/components/NotificationBell.vue create mode 100644 apps/web/src/pages/AccountPage.vue create mode 100644 apps/web/src/pages/QuotaPage.vue create mode 100644 docs/architecture/m6-plan.md create mode 100644 docs/architecture/ops-api.md create mode 100644 server/migrations/versions/0010_subscription_lifecycle.py create mode 100644 server/migrations/versions/0011_ops_role_grants.py create mode 100644 server/migrations/versions/0012_ops_admin_role.py create mode 100644 server/src/bid_doctor/billing/tasks.py create mode 100644 server/src/bid_doctor/identity/closure.py create mode 100644 server/src/bid_doctor/ops/app.py create mode 100644 server/src/bid_doctor/ops/auth.py create mode 100644 server/src/bid_doctor/ops/console.py create mode 100644 server/src/bid_doctor/ops/console_api.py create mode 100644 server/tests/test_billing_flow.py create mode 100644 server/tests/test_ops_console.py 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 @@ + + + + + + + 标博士运营后台 + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..341c6d6 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,25 @@ +{ + "name": "@bid-doctor/admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "typecheck": "vue-tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/barlow-semi-condensed": "5.3.0", + "@lucide/vue": "1.50.0", + "vue": "3.5.43", + "vue-router": "4.6.4" + }, + "devDependencies": { + "@types/node": "24.19.2", + "@vitejs/plugin-vue": "6.0.9", + "typescript": "5.8.3", + "vite": "7.3.7", + "vue-tsc": "3.3.12" + } +} diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue new file mode 100644 index 0000000..7b74945 --- /dev/null +++ b/apps/admin/src/App.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/admin/src/OpsSidebar.vue b/apps/admin/src/OpsSidebar.vue new file mode 100644 index 0000000..6f6a39c --- /dev/null +++ b/apps/admin/src/OpsSidebar.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/apps/admin/src/admin.css b/apps/admin/src/admin.css new file mode 100644 index 0000000..88ae532 --- /dev/null +++ b/apps/admin/src/admin.css @@ -0,0 +1,15 @@ +/* 运营后台的框架(决策 M6-3):颜色、字号、间距只用 tokens.css 的变量;按钮、表格、卡片沿用用户端的组件样式 */ +body{margin:0;background:var(--bg-page);color:var(--text-primary)} +.ops-frame{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh;min-width:1280px} +.ops-main{min-width:0} +.ops-main .content{max-width:1440px;padding-top:var(--sp-8)} +.toolbar{display:flex;gap:var(--sp-3);align-items:center;margin-bottom:var(--sp-4)} +.toolbar .input{max-width:360px} +.card{margin-bottom:var(--gap-card)} +.card.flush > .card-h{padding:var(--sp-4) var(--card-x) 0} +table.data td{vertical-align:top} +.nowrap{white-space:nowrap} +.r{text-align:right} +.acts{display:flex;flex-wrap:wrap;gap:var(--sp-2)} +.err-text{color:var(--warn-text)} +.inv{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)} diff --git a/apps/admin/src/api.ts b/apps/admin/src/api.ts new file mode 100644 index 0000000..a8ea053 --- /dev/null +++ b/apps/admin/src/api.ts @@ -0,0 +1,155 @@ +/** + * 运营后台接口(/ops/v1,docs/architecture/ops-api.md)。内部工具,类型在这里手写;cookie 会话 + CSRF 头。 + */ +export class OpsError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +/** 页面上做完操作(开通、退回……)发这个事件,菜单上的待办数跟着刷新 */ +export const CHANGED = "ops:changed"; +export const notifyChanged = () => window.dispatchEvent(new Event(CHANGED)); + +let onUnauthorized: () => void = () => {}; +export function setUnauthorized(fn: () => void) { + onUnauthorized = fn; +} + +function csrf(): string { + return document.cookie.split("; ").find((c) => c.startsWith("bd_ops_csrf="))?.split("=")[1] ?? ""; +} + +export async function ops(path: string, init: { method?: string; body?: unknown; query?: Record } = {}): Promise { + const qs = new URLSearchParams(Object.entries(init.query ?? {}).filter((e): e is [string, string] => !!e[1])).toString(); + const res = await fetch(`/ops/v1${path}${qs ? `?${qs}` : ""}`, { + method: init.method ?? "GET", + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.method && init.method !== "GET" ? { "X-CSRF-Token": csrf() } : {}) }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + if (res.status === 204) return undefined as T; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (res.status === 401 && path !== "/auth/login") onUnauthorized(); + throw new OpsError(res.status, data.code ?? "error", data.detail ?? data.title ?? "请求失败"); + } + return data as T; +} + +/** 时间一律按北京时间显示:「2026-11-05 14:45」 */ +const BJ = new Intl.DateTimeFormat("sv-SE", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); +export const fmt = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)) : "—"); +export const day = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)).slice(0, 10) : "—"); + +export type Tenant = { + id: string; + name: string; + status: string; + created_at: string; + plan: { code: string | null; expired: boolean; ends_at: string | null }; + balance: number | null; + projects: number; + phone_hint: string | null; + open_requests: number; +}; +export type TenantDetail = Tenant & { + members: { display_name: string; role: string; status: string; phone_hint: string | null; last_login_at: string | null }[]; + subscriptions: { plan_code: string; status: string; source: string; starts_at: string; ends_at: string | null; request_no: string | null }[]; + quota_entries: { kind: string; amount: number; reason: string | null; ref_type: string | null; created_at: string; unit: string; by_ops: boolean }[]; + jobs: Record>; +}; +export type ActivationRequest = { + id: string; + request_no: string; + tenant: { id: string; name: string }; + plan_code: string; + status: "submitted" | "paid" | "activated" | "rejected" | "cancelled"; + invoice_info: Record; + ops_note: string | null; + invoice_no: string | null; + applicant_phone_hint: string | null; + created_at: string; + handled_at: string | null; +}; +export type Feedback = { + id: string; + kind: string; + source: string; + note: string | null; + object_type: string; + tenant_name: string; + title: string | null; + severity: string | null; + rule_code: string | null; + detector: string | null; + finding_type: string | null; + rule_set_version: string | null; + prompt_versions: Record | null; + pages: number[]; + created_at: string; +}; +export type FailedJob = { + kind: string; + id: string; + tenant: { id: string; name: string }; + stage: string | null; + error_class: string | null; + error_code: string | null; + error_detail: string | null; + created_at: string; + duration_s: number | null; + ai_calls: number; +}; +export type AuditEvent = { + id: string; + tenant: { id: string; name: string }; + actor_kind: string; + actor_name: string | null; + action: string; + object_type: string; + object_id: string | null; + project_id: string | null; + scrubbed: boolean; + created_at: string; +}; +export type Operator = { + id: string; + username: string; + display_name: string; + role: string; + role_label: string; + permissions: string[]; + env: "dev" | "test" | "prod"; +}; +export type Summary = { open_requests: number; failed_jobs_7d: number; feedback_7d: number }; + +export const REQUEST_STATUS: Record = { + submitted: "等待转账", + paid: "已到账", + activated: "已开通", + rejected: "已退回", + cancelled: "已撤回", +}; +export const QUOTA_KIND: Record = { + grant: "发放", + hold: "预扣", + settle: "结算", + release: "退回", + expire: "过期", + adjust: "调整", + waive: "免扣", +}; +export const JOB_KIND: Record = { parse_job: "解析", check_run: "检查", report_job: "报告" }; diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts new file mode 100644 index 0000000..c1320d0 --- /dev/null +++ b/apps/admin/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from "vue"; +import "@fontsource/barlow-semi-condensed/500.css"; +import "@fontsource/barlow-semi-condensed/600.css"; +import "@design/system/tokens.css"; +import "@design/system/components.css"; +import "@web-styles/app.css"; +import "./admin.css"; +import App from "./App.vue"; +import { router } from "./router"; + +createApp(App).use(router).mount("#app"); diff --git a/apps/admin/src/nav.ts b/apps/admin/src/nav.ts new file mode 100644 index 0000000..72667db --- /dev/null +++ b/apps/admin/src/nav.ts @@ -0,0 +1,49 @@ +/** + * 运营后台的菜单:分组(像文件夹,可以展开收起)→ 菜单项。每一项写清要哪个权限点(ops/auth.py 的 OpsPermission), + * 没有这个权限的人看不到;一个分组下都看不到时整组不显示。以后加页面、加分组、加二级子菜单都只改这里。 + */ +import { Building2, FileClock, MessageSquareWarning, ReceiptText, ScrollText, ServerCrash, type LucideIcon } from "@lucide/vue"; +import type { Summary } from "./api"; + +export type NavItem = { + name: string; // 路由名 + label: string; + icon: LucideIcon; + perm: string; + /** 菜单上的待办数(来自 /summary) */ + badge?: keyof Summary; + /** 这些路由也算在这一项下面(详情页) */ + also?: string[]; +}; +export type NavGroup = { key: string; label: string; icon: LucideIcon; items: NavItem[] }; + +export const NAV: NavGroup[] = [ + { + key: "customers", + label: "客户", + icon: Building2, + items: [ + { name: "requests", label: "开通申请", icon: ReceiptText, perm: "activation.manage", badge: "open_requests" }, + { name: "tenants", label: "企业", icon: Building2, perm: "tenant.view", also: ["tenant"] }, + ], + }, + { + key: "quality", + label: "质量", + icon: MessageSquareWarning, + items: [ + { name: "feedback", label: "误报 / 漏报", icon: MessageSquareWarning, perm: "feedback.view", badge: "feedback_7d" }, + { name: "failed-jobs", label: "失败任务", icon: ServerCrash, perm: "jobs.view", badge: "failed_jobs_7d" }, + ], + }, + { + key: "security", + label: "安全", + icon: ScrollText, + items: [{ name: "audit", label: "审计", icon: FileClock, perm: "audit.view" }], + }, +]; + +export function visibleNav(perms: string[]): NavGroup[] { + return NAV.map((g) => ({ ...g, items: g.items.filter((i) => perms.includes(i.perm)) })).filter((g) => g.items.length); +} diff --git a/apps/admin/src/pages/AuditPage.vue b/apps/admin/src/pages/AuditPage.vue new file mode 100644 index 0000000..caa97cf --- /dev/null +++ b/apps/admin/src/pages/AuditPage.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/admin/src/pages/FailedJobsPage.vue b/apps/admin/src/pages/FailedJobsPage.vue new file mode 100644 index 0000000..9d5f75b --- /dev/null +++ b/apps/admin/src/pages/FailedJobsPage.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/admin/src/pages/FeedbackPage.vue b/apps/admin/src/pages/FeedbackPage.vue new file mode 100644 index 0000000..d1f0fff --- /dev/null +++ b/apps/admin/src/pages/FeedbackPage.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/admin/src/pages/LoginPage.vue b/apps/admin/src/pages/LoginPage.vue new file mode 100644 index 0000000..a628e24 --- /dev/null +++ b/apps/admin/src/pages/LoginPage.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/apps/admin/src/pages/RequestsPage.vue b/apps/admin/src/pages/RequestsPage.vue new file mode 100644 index 0000000..6eee09c --- /dev/null +++ b/apps/admin/src/pages/RequestsPage.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/apps/admin/src/pages/TenantPage.vue b/apps/admin/src/pages/TenantPage.vue new file mode 100644 index 0000000..4aeb35b --- /dev/null +++ b/apps/admin/src/pages/TenantPage.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/apps/admin/src/pages/TenantsPage.vue b/apps/admin/src/pages/TenantsPage.vue new file mode 100644 index 0000000..1b3b6dd --- /dev/null +++ b/apps/admin/src/pages/TenantsPage.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/admin/src/router.ts b/apps/admin/src/router.ts new file mode 100644 index 0000000..a52cf87 --- /dev/null +++ b/apps/admin/src/router.ts @@ -0,0 +1,20 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { setUnauthorized } from "./api"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/login", name: "login", component: () => import("./pages/LoginPage.vue"), meta: { bare: true } }, + { path: "/", redirect: "/requests" }, + { path: "/requests", name: "requests", component: () => import("./pages/RequestsPage.vue") }, + { path: "/tenants", name: "tenants", component: () => import("./pages/TenantsPage.vue") }, + { path: "/tenants/:id", name: "tenant", component: () => import("./pages/TenantPage.vue"), props: true }, + { path: "/feedback", name: "feedback", component: () => import("./pages/FeedbackPage.vue") }, + { path: "/failed-jobs", name: "failed-jobs", component: () => import("./pages/FailedJobsPage.vue") }, + { path: "/audit", name: "audit", component: () => import("./pages/AuditPage.vue") }, + ], +}); + +setUnauthorized(() => { + if (router.currentRoute.value.name !== "login") void router.replace({ name: "login" }); +}); diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..6be70d5 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "noEmit": true, + "skipLibCheck": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..04d038d --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,25 @@ +import { fileURLToPath, URL } from "node:url"; +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +/** + * 运营后台(决策 M6-3):单独构建、单独部署,私有化不交付。开发时跑在 127.0.0.1:5174, + * 把 /ops 代理到 ops 进程 127.0.0.1:8019(bid-doctor ops)。样式只来自 docs/design/system。 + */ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + "@design": fileURLToPath(new URL("../../docs/design", import.meta.url)), + // 和用户端共用组件样式(按钮、输入框、表格、卡片……),不复制(决策 M6-3) + "@web-styles": fileURLToPath(new URL("../web/src/styles", import.meta.url)), + }, + }, + server: { + host: "127.0.0.1", + port: 5174, + strictPort: true, + proxy: { "/ops": { target: "http://127.0.0.1:8019", changeOrigin: false } }, + }, +}); diff --git a/apps/web/src/components/ActivationDialog.vue b/apps/web/src/components/ActivationDialog.vue new file mode 100644 index 0000000..4214fca --- /dev/null +++ b/apps/web/src/components/ActivationDialog.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/apps/web/src/components/AppShell.vue b/apps/web/src/components/AppShell.vue index a670564..c1b1596 100644 --- a/apps/web/src/components/AppShell.vue +++ b/apps/web/src/components/AppShell.vue @@ -3,11 +3,12 @@ * 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。 * 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。 */ -import { Award, House, LogOut } from "@lucide/vue"; +import { Award, House, LogOut, Wallet } from "@lucide/vue"; import { useQueryClient } from "@tanstack/vue-query"; import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { RouterLink, RouterView, useRoute, useRouter } from "vue-router"; import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url"; +import NotificationBell from "./NotificationBell.vue"; import Toasts from "./Toasts.vue"; import { api } from "@/lib/api"; import { provideCrumbs } from "@/lib/crumbs"; @@ -74,12 +75,15 @@ async function logout() { 资质库 + + 额度 + -
+ {{ name }} {{ plan.name }} -
+
@@ -93,18 +97,25 @@ async function logout() {
- + - + +
+ +
+ +

专业版已到期,数据只读:可以查看、下载已导出的报告,不能新建项目、解析和检查。

+ 续费 +
diff --git a/apps/web/src/components/NotificationBell.vue b/apps/web/src/components/NotificationBell.vue new file mode 100644 index 0000000..567da83 --- /dev/null +++ b/apps/web/src/components/NotificationBell.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/apps/web/src/components/ProjectHead.vue b/apps/web/src/components/ProjectHead.vue index 1da7f0e..64f7216 100644 --- a/apps/web/src/components/ProjectHead.vue +++ b/apps/web/src/components/ProjectHead.vue @@ -4,11 +4,14 @@ * 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。 * 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明; * 递交后项目只读,可以撤销,都留痕。 + * 删除项目(J4,决策 M6-1):放在「更多」里,彻底删除、不能恢复,确认时要输入项目名称;额度流水和不含内容的审计保留。 */ import { unwrap } from "@bid-doctor/api-client"; +import { MoreHorizontal } from "@lucide/vue"; import { useQueryClient } from "@tanstack/vue-query"; +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from "reka-ui"; import { computed, ref } from "vue"; -import { RouterLink } from "vue-router"; +import { RouterLink, useRouter } from "vue-router"; import ConfirmDialog from "@/components/ConfirmDialog.vue"; import { api, errorTitle } from "@/lib/api"; import { qk } from "@/lib/keys"; @@ -57,6 +60,38 @@ async function run(action: Action) { } } +/* ---------- 删除项目 ---------- */ +const router = useRouter(); +const delOpen = ref(false); +const delName = ref(""); +const delErr = ref(""); +function askDelete() { + delName.value = ""; + delErr.value = ""; + delOpen.value = true; +} +async function doDelete() { + if (!p.value) return; + if (delName.value.trim() !== p.value.name) { + delErr.value = "项目名称不一致,请照上面的名称输入"; + return; + } + busy.value = true; + try { + await unwrap(api.DELETE("/projects/{project_id}", { params: { path: { project_id: p.value.id } } })); + delOpen.value = false; + qc.removeQueries({ queryKey: qk.project(p.value.id) }); + void qc.invalidateQueries({ queryKey: qk.projects }); + void qc.invalidateQueries({ queryKey: qk.quota }); + toast(`已删除项目「${p.value.name}」`); + await router.replace({ name: "workbench" }); + } catch (e) { + delErr.value = errorTitle(e); + } finally { + busy.value = false; + } +} + const checkTo = computed(() => p.value?.latest_check_run_id ? { name: "results", params: { projectId: p.value.id, runId: p.value.latest_check_run_id } } @@ -84,6 +119,14 @@ const checkTo = computed(() =>
{{ projectStatus(p.status) }} + + + + + 删除项目 + + +
@@ -106,4 +149,19 @@ const checkTo = computed(() =>

标记后项目改为只读,可以随时撤销。

还有 {{ p.readiness.open_fatal_count }} 项致命问题没有处理。是否递交由你决定,系统不会阻止。

+ +

项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,不能恢复。需要留底的报告请先下载。

+

额度流水和一条不含业务内容的操作记录会保留。

+
+ + + {{ delErr }} +
+
+ + diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts index 8e0e84b..2fcc9f5 100644 --- a/apps/web/src/lib/events.ts +++ b/apps/web/src/lib/events.ts @@ -37,6 +37,7 @@ function onEvent(qc: QueryClient, e: BdEvent) { if (e.type === "job.finished") { // 结束后要拿完整结果(error、result),并刷新项目、额度、清单 void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) }); + void qc.invalidateQueries({ queryKey: qk.notifications }); void qc.invalidateQueries({ queryKey: qk.projects }); void qc.invalidateQueries({ queryKey: qk.quota }); if (e.project_id) { @@ -55,6 +56,7 @@ function onEvent(qc: QueryClient, e: BdEvent) { ); if (e.type === "job.finished") { void qc.invalidateQueries({ queryKey: qk.checkRun(jobId) }); + void qc.invalidateQueries({ queryKey: qk.notifications }); void qc.invalidateQueries({ queryKey: qk.findings(jobId) }); void qc.invalidateQueries({ queryKey: qk.projects }); void qc.invalidateQueries({ queryKey: qk.quota }); @@ -69,6 +71,7 @@ function onEvent(qc: QueryClient, e: BdEvent) { switch (e.type) { case "quota.changed": void qc.invalidateQueries({ queryKey: qk.quota }); + void qc.invalidateQueries({ queryKey: qk.notifications }); break; case "project.changed": void qc.invalidateQueries({ queryKey: qk.projects }); diff --git a/apps/web/src/lib/keys.ts b/apps/web/src/lib/keys.ts index 8503544..ccf8b41 100644 --- a/apps/web/src/lib/keys.ts +++ b/apps/web/src/lib/keys.ts @@ -2,6 +2,10 @@ export const qk = { me: ["me"] as const, quota: ["quota"] as const, + /** 前缀和 quota 一样:额度一变,流水和申请跟着失效 */ + quotaEntries: ["quota", "entries"] as const, + activationRequests: ["quota", "activation-requests"] as const, + notifications: ["notifications"] as const, plans: ["plans"] as const, projects: ["projects"] as const, project: (id: string) => ["project", id] as const, diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 96000ec..ce52928 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -1,6 +1,6 @@ /** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */ import { unwrap, type Schemas } from "@bid-doctor/api-client"; -import { useQuery } from "@tanstack/vue-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/vue-query"; import { computed, toValue, type MaybeRefOrGetter } from "vue"; import { api } from "./api"; import { qk } from "./keys"; @@ -46,6 +46,34 @@ export function useQuotaAccount(unit: Schemas["QuotaUnit"]) { return computed(() => q.data.value?.accounts.find((a) => a.unit === unit)); } +export type QuotaEntry = Schemas["QuotaEntry"]; +export type ActivationRequest = Schemas["ActivationRequest"]; +export type Notification = Schemas["Notification"]; + +/** 额度流水:按时间倒序,按游标一页页往后取(「加载更多」) */ +export function useQuotaEntries() { + return useInfiniteQuery({ + queryKey: qk.quotaEntries, + queryFn: ({ pageParam }) => + unwrap(api.GET("/quota/entries", { params: { query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) } } })), + initialPageParam: "", + getNextPageParam: (last) => last.next_cursor ?? undefined, + }); +} + +export function useActivationRequests() { + return useQuery({ queryKey: qk.activationRequests, queryFn: () => unwrap(api.GET("/activation-requests")) }); +} + +/** 站内通知:顶栏铃铛。事件流在任务结束时让它失效;另外每分钟兜底刷新一次(运营开通、到期提醒不走事件流) */ +export function useNotifications() { + return useQuery({ + queryKey: qk.notifications, + queryFn: () => unwrap(api.GET("/notifications", { params: { query: { limit: 30 } } })), + refetchInterval: 60_000, + }); +} + export function usePlans() { return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 }); } diff --git a/apps/web/src/pages/AccountPage.vue b/apps/web/src/pages/AccountPage.vue new file mode 100644 index 0000000..c3f01c0 --- /dev/null +++ b/apps/web/src/pages/AccountPage.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/apps/web/src/pages/QuotaPage.vue b/apps/web/src/pages/QuotaPage.vue new file mode 100644 index 0000000..da6f8d3 --- /dev/null +++ b/apps/web/src/pages/QuotaPage.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/apps/web/src/pages/WorkbenchPage.vue b/apps/web/src/pages/WorkbenchPage.vue index 320c050..ac825ba 100644 --- a/apps/web/src/pages/WorkbenchPage.vue +++ b/apps/web/src/pages/WorkbenchPage.vue @@ -23,6 +23,8 @@ const projects = useProjects(); const quota = useQuota(); const parse = useQuotaAccount("parse"); +/** 套餐到期后只读(决策 M6-5):不能新建项目,顶上有续费提醒 */ +const readOnly = computed(() => !!me.data.value?.plan.read_only); const today = new Date(); const hello = computed(() => { const name = me.data.value?.user.display_name ?? ""; @@ -87,7 +89,7 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
解析 1 份招标文件扣 1 次 · 剩 {{ parse.balance }} 次 - 新建项目 + 新建项目
@@ -131,7 +133,7 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
还没有项目

上传招标文件,标博士会逐条提取要求,每一条都带原文出处。

- 新建项目 + 新建项目
diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 7d2e15e..dba1491 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -25,6 +25,8 @@ export const router = createRouter({ { path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") }, { path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") }, { path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.vue") }, + { path: "quota", name: "quota", component: () => import("@/pages/QuotaPage.vue") }, + { path: "account", name: "account", component: () => import("@/pages/AccountPage.vue") }, { path: "projects/:projectId/parse/:jobId", name: "parse", diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index b0e9937..d3ab645 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -48,6 +48,13 @@ .quota-chip .fig{color:var(--text-primary);font-size:var(--fs-figure-sm)} .quota-chip .q.warn .fig{color:var(--warn-text);font-weight:var(--fw-bold)} .quota-chip .sep{width:1px;height:12px;margin:0 var(--sp-1);background:var(--border-subtle)} +a.quota-chip:hover{background:var(--c-n100)} +/* 侧栏底部的账号区可以点(进账号页) */ +a.me{text-decoration:none;border-radius:var(--radius-ctl);transition:background-color var(--dur-fast) var(--ease)} +a.me:hover{background:var(--side-hover)}a.me[aria-current="page"]{background:var(--side-active)} +/* 套餐到期的提醒条(决策 M6-5):赭色,不用红(U1) */ +.expired-bar{display:flex;gap:var(--sp-3);align-items:center;margin:0 var(--pad-page) var(--gap-card);padding:10px var(--card-x);background:var(--bg-surface);border:var(--line) solid var(--warn);border-radius:var(--radius-card)} +.expired-bar p{flex:1;margin:0;color:var(--warn-text)} .top .vsep{flex:none;width:1px;height:20px;margin:0 var(--sp-1);background:var(--border-subtle)} .content{width:100%;padding:20px var(--pad-page) var(--sp-12)} .content.narrow{max-width:800px} diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 165599b..03bbddf 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -12,6 +12,7 @@ services: POSTGRES_PASSWORD: ${BD_PG_OWNER_PASSWORD:-dev-owner} POSTGRES_DB: bid_doctor BD_APP_PASSWORD: ${BD_PG_APP_PASSWORD:-dev-app} + BD_OPS_PASSWORD: ${BD_PG_OPS_PASSWORD:-dev-ops} ports: ["127.0.0.1:${BD_PG_PORT:-54329}:5432"] volumes: - pgdata:/var/lib/postgresql/data @@ -68,6 +69,21 @@ services: migrate: { condition: service_completed_successfully } valkey: { condition: service_healthy } + # 运营后台接口(决策 M6-2):同一镜像的第三种进程,只绑本机回环;IP 白名单放行本机经 Docker 网桥进来的请求 + ops: + profiles: [app] + image: bid-doctor-server + command: ["bid-doctor", "ops", "--host", "0.0.0.0", "--port", "8000"] + env_file: [{ path: ../../.env, required: false }] + environment: + <<: *app-env + BD_OPS_DATABASE_URL: postgresql+psycopg://bd_ops:${BD_PG_OPS_PASSWORD:-dev-ops}@postgres:5432/bid_doctor + BD_OPS_ALLOWED_IPS: '["127.0.0.1/32", "172.16.0.0/12"]' + ports: ["127.0.0.1:${BD_OPS_PORT:-8019}:8000"] + depends_on: + migrate: { condition: service_completed_successfully } + valkey: { condition: service_healthy } + worker: profiles: [app] image: bid-doctor-server diff --git a/deploy/compose/postgres/init-roles.sh b/deploy/compose/postgres/init-roles.sh index 6fe6c07..44ff398 100755 --- a/deploy/compose/postgres/init-roles.sh +++ b/deploy/compose/postgres/init-roles.sh @@ -1,9 +1,11 @@ #!/bin/sh # 数据库角色(data-model §2.4):bd_owner 建表和迁移;bd_app 给 api / worker,受行级安全策略约束; -# bd_auth 不登录,只拥有登录前查会话的两个函数。只在数据卷第一次初始化时执行。 +# bd_auth 不登录,只拥有登录前查会话的两个函数;bd_ops 给运营后台的跨企业查询(决策 M6-2)。 +# 只在数据卷第一次初始化时执行;已有的数据卷要手工补建 bd_ops(见 docs/architecture/m6-plan.md)。 set -eu psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" </results' } + project_name: { type: [string, 'null'] } + link: { type: [string, 'null'], description: '前端路由,如 /projects//results、/quota' } read_at: oneOf: - $ref: '#/components/schemas/Timestamp' diff --git a/docs/architecture/ops-api.md b/docs/architecture/ops-api.md new file mode 100644 index 0000000..841e826 --- /dev/null +++ b/docs/architecture/ops-api.md @@ -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" +``` diff --git a/docs/decisions.md b/docs/decisions.md index f671646..b1dc97d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,23 @@ --- +## M6-1–M6-8 · 2026-11-05 · M6 剩下的 P0:全部按建议 + +依据 `docs/architecture/m6-plan.md` §4。 + +| # | 决策 | 理由 | +|---|---|---| +| M6-1 | 删除项目立即彻底删除(销毁项目数据密钥),不做 30 天回收站;确认框要求输入项目名称;原型「30 天内可以在回收站恢复」的文案作废 | 按已确认的 G10、DM4;回收站要软删除,和「彻底删除」冲突 | +| M6-2 | 运营接口是同一代码的第三种进程 `bid-doctor ops`(单独端口、`/ops/v1`、`bd_ops` 角色),用户端 api 进程里没有运营接口 | 绕过行级安全策略的代码不会被公网请求触到 | +| M6-3 | 运营后台前端 `apps/admin`:Vue 3 + 复用 `tokens.css` 和组件样式,不引入带样式框架 | 页面少、以表格表单为主,和用户端一套组件 | +| M6-4 | 运营登录:用户名 + 密码 + TOTP + IP 白名单,账号命令行创建 | 架构 §3.11 | +| M6-5 | 套餐到期不自动降为免费版;项目只读(能看、能下载已导出的报告、能删除),额度清零;提前 30 天、7 天、当天站内提醒;续费到账后恢复 | FR-M6-05「数据保留,只读;额度清零」 | +| M6-6 | 开通申请页不写价格(D2 未定),写「运营会联系你确认金额」;收款信息从配置读,生产环境没配拒绝启动 | 定价没定;收款账户不进代码 | +| M6-7 | 运营后台看反馈不看招标 / 投标原文,只看标题、理由、规则编号、提示词版本、页码 | 红线 10 | +| M6-8 | 注销账号要输入企业名称 + 短信验证码;有没到期的专业版时写明剩余额度作废、不退款 | 不可恢复的操作,二次确认 | +| M6-9 | 运营后台菜单放左侧,分组像文件夹一样可以展开收起,每项配图标;左上角用标准横版 logo(反白) | 用户提出(2026-11-05);页面会越来越多 | +| M6-10 | 运营人员以后会分管理员、运营、销售、运维等角色:现在**只留结构**——`ops_admin.role`(不加 CHECK)、代码里的权限点和「角色 → 权限点」对应表、每个运营接口都过检查点、菜单按权限点过滤;现在只有「管理员」一种,拥有全部权限。具体有哪些角色、各自能做什么,加角色时再定 | 用户提出(2026-11-05);按「以后的功能只留结构」的做法(同 AR11、AR12) | + ## E3 · 2026-11-05 · 国动真实投标文件暴露的 6 处全部修 - **决策**:国动真实投标文件跑完一次(`check-eval.md` §14)后,暴露的 6 处全部修:响应错位的复合肯定写法、附加条件的否定句、扫描件判断、不需要这家投标人回答的条目、提纲式的「方案应明确……」、同一批片段的出处核对。规则集 `2026.11-r5`。 diff --git a/docs/prototype/project.html b/docs/prototype/project.html index 65e271e..bcb8309 100644 --- a/docs/prototype/project.html +++ b/docs/prototype/project.html @@ -210,7 +210,7 @@ function act(k) { else if (k === "unarchive") { setStatus(before === "已归档" ? "已检查" : before); toast("已撤销归档", { ok: false }); } else if (k === "delete") dialog({ title: `删除项目「${esc(D.project.name)}」?`, - body: `

项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 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//results */ + project_name?: string | null; + /** @description 前端路由,如 /projects//results、/quota */ link?: string | null; read_at?: components["schemas"]["Timestamp"] | null; created_at: components["schemas"]["Timestamp"]; @@ -2650,6 +2701,36 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + sendMySmsCode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + purpose: "close_tenant"; + }; + }; + }; + responses: { + /** @description 已发送 */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + resend_after_s: number; + }; + }; + }; + 429: components["responses"]["RateLimited"]; + }; + }; closeTenant: { parameters: { query?: never; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5eed35..2ba0dcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,37 @@ importers: .: {} + apps/admin: + dependencies: + '@fontsource/barlow-semi-condensed': + specifier: 5.3.0 + version: 5.3.0 + '@lucide/vue': + specifier: 1.50.0 + version: 1.50.0(vue@3.5.43(typescript@5.8.3)) + vue: + specifier: 3.5.43 + version: 3.5.43(typescript@5.8.3) + vue-router: + specifier: 4.6.4 + version: 4.6.4(vue@3.5.43(typescript@5.8.3)) + devDependencies: + '@types/node': + specifier: 24.19.2 + version: 24.19.2 + '@vitejs/plugin-vue': + specifier: 6.0.9 + version: 6.0.9(vite@7.3.7(@types/node@24.19.2))(vue@3.5.43(typescript@5.8.3)) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vite: + specifier: 7.3.7 + version: 7.3.7(@types/node@24.19.2) + vue-tsc: + specifier: 3.3.12 + version: 3.3.12(typescript@5.8.3) + apps/web: dependencies: '@bid-doctor/api-client': diff --git a/server/migrations/versions/0010_subscription_lifecycle.py b/server/migrations/versions/0010_subscription_lifecycle.py new file mode 100644 index 0000000..309cafc --- /dev/null +++ b/server/migrations/versions/0010_subscription_lifecycle.py @@ -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')); +""") diff --git a/server/migrations/versions/0011_ops_role_grants.py b/server/migrations/versions/0011_ops_role_grants.py new file mode 100644 index 0000000..6235e6a --- /dev/null +++ b/server/migrations/versions/0011_ops_role_grants.py @@ -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; +""") diff --git a/server/migrations/versions/0012_ops_admin_role.py b/server/migrations/versions/0012_ops_admin_role.py new file mode 100644 index 0000000..b965dbb --- /dev/null +++ b/server/migrations/versions/0012_ops_admin_role.py @@ -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") diff --git a/server/src/bid_doctor/audit/models.py b/server/src/bid_doctor/audit/models.py index 0bd5492..59befcf 100644 --- a/server/src/bid_doctor/audit/models.py +++ b/server/src/bid_doctor/audit/models.py @@ -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] diff --git a/server/src/bid_doctor/audit/service.py b/server/src/bid_doctor/audit/service.py index 5319870..3a5cb30 100644 --- a/server/src/bid_doctor/audit/service.py +++ b/server/src/bid_doctor/audit/service.py @@ -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, diff --git a/server/src/bid_doctor/billing/api.py b/server/src/bid_doctor/billing/api.py index ef806e1..88fb04d 100644 --- a/server/src/bid_doctor/billing/api.py +++ b/server/src/bid_doctor/billing/api.py @@ -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())) diff --git a/server/src/bid_doctor/billing/models.py b/server/src/bid_doctor/billing/models.py index 63f9d8d..927ab2f 100644 --- a/server/src/bid_doctor/billing/models.py +++ b/server/src/bid_doctor/billing/models.py @@ -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] diff --git a/server/src/bid_doctor/billing/service.py b/server/src/bid_doctor/billing/service.py index 7cadee6..dc56453 100644 --- a/server/src/bid_doctor/billing/service.py +++ b/server/src/bid_doctor/billing/service.py @@ -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, + ) diff --git a/server/src/bid_doctor/billing/tasks.py b/server/src/bid_doctor/billing/tasks.py new file mode 100644 index 0000000..3cefbf4 --- /dev/null +++ b/server/src/bid_doctor/billing/tasks.py @@ -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) diff --git a/server/src/bid_doctor/check/service.py b/server/src/bid_doctor/check/service.py index e5c2da4..70197c3 100644 --- a/server/src/bid_doctor/check/service.py +++ b/server/src/bid_doctor/check/service.py @@ -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 diff --git a/server/src/bid_doctor/cli.py b/server/src/bid_doctor/cli.py index c18b30f..392d334 100644 --- a/server/src/bid_doctor/cli.py +++ b/server/src/bid_doctor/cli.py @@ -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 diff --git a/server/src/bid_doctor/core/config.py b/server/src/bid_doctor/core/config.py index 71ca18b..8046596 100644 --- a/server/src/bid_doctor/core/config.py +++ b/server/src/bid_doctor/core/config.py @@ -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 diff --git a/server/src/bid_doctor/core/crypto.py b/server/src/bid_doctor/core/crypto.py index 372db74..4573c6c 100644 --- a/server/src/bid_doctor/core/crypto.py +++ b/server/src/bid_doctor/core/crypto.py @@ -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 秒 diff --git a/server/src/bid_doctor/core/db.py b/server/src/bid_doctor/core/db.py index 1454f8d..8aa3b58 100644 --- a/server/src/bid_doctor/core/db.py +++ b/server/src/bid_doctor/core/db.py @@ -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 diff --git a/server/src/bid_doctor/core/errors.py b/server/src/bid_doctor/core/errors.py index 719a2db..8702e55 100644 --- a/server/src/bid_doctor/core/errors.py +++ b/server/src/bid_doctor/core/errors.py @@ -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, "这类文件不需要单独解析"), diff --git a/server/src/bid_doctor/core/ids.py b/server/src/bid_doctor/core/ids.py index 1412a5b..92735ab 100644 --- a/server/src/bid_doctor/core/ids.py +++ b/server/src/bid_doctor/core/ids.py @@ -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) diff --git a/server/src/bid_doctor/core/keys.py b/server/src/bid_doctor/core/keys.py index 461fbc7..adbb4fd 100644 --- a/server/src/bid_doctor/core/keys.py +++ b/server/src/bid_doctor/core/keys.py @@ -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}, + ) diff --git a/server/src/bid_doctor/core/storage.py b/server/src/bid_doctor/core/storage.py index 972a933..d7187cf 100644 --- a/server/src/bid_doctor/core/storage.py +++ b/server/src/bid_doctor/core/storage.py @@ -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}" diff --git a/server/src/bid_doctor/document/service.py b/server/src/bid_doctor/document/service.py index 2b1ec34..d31c2e8 100644 --- a/server/src/bid_doctor/document/service.py +++ b/server/src/bid_doctor/document/service.py @@ -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 diff --git a/server/src/bid_doctor/identity/api.py b/server/src/bid_doctor/identity/api.py index 681335f..f761db6 100644 --- a/server/src/bid_doctor/identity/api.py +++ b/server/src/bid_doctor/identity/api.py @@ -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) diff --git a/server/src/bid_doctor/identity/closure.py b/server/src/bid_doctor/identity/closure.py new file mode 100644 index 0000000..3187f9e --- /dev/null +++ b/server/src/bid_doctor/identity/closure.py @@ -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)) diff --git a/server/src/bid_doctor/identity/deps.py b/server/src/bid_doctor/identity/deps.py index 6dff4ff..215b470 100644 --- a/server/src/bid_doctor/identity/deps.py +++ b/server/src/bid_doctor/identity/deps.py @@ -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 diff --git a/server/src/bid_doctor/notification/api.py b/server/src/bid_doctor/notification/api.py index eb7b02a..1fa1661 100644 --- a/server/src/bid_doctor/notification/api.py +++ b/server/src/bid_doctor/notification/api.py @@ -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())) diff --git a/server/src/bid_doctor/ops/app.py b/server/src/bid_doctor/ops/app.py new file mode 100644 index 0000000..d20a92d --- /dev/null +++ b/server/src/bid_doctor/ops/app.py @@ -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 diff --git a/server/src/bid_doctor/ops/auth.py b/server/src/bid_doctor/ops/auth.py new file mode 100644 index 0000000..d4af7d3 --- /dev/null +++ b/server/src/bid_doctor/ops/auth.py @@ -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) diff --git a/server/src/bid_doctor/ops/console.py b/server/src/bid_doctor/ops/console.py new file mode 100644 index 0000000..3207c81 --- /dev/null +++ b/server/src/bid_doctor/ops/console.py @@ -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]} diff --git a/server/src/bid_doctor/ops/console_api.py b/server/src/bid_doctor/ops/console_api.py new file mode 100644 index 0000000..6a8544a --- /dev/null +++ b/server/src/bid_doctor/ops/console_api.py @@ -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)} diff --git a/server/src/bid_doctor/ops/models.py b/server/src/bid_doctor/ops/models.py index 02bcc4c..4763fa0 100644 --- a/server/src/bid_doctor/ops/models.py +++ b/server/src/bid_doctor/ops/models.py @@ -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] diff --git a/server/src/bid_doctor/project/api.py b/server/src/bid_doctor/project/api.py index b70f798..63a4ff6 100644 --- a/server/src/bid_doctor/project/api.py +++ b/server/src/bid_doctor/project/api.py @@ -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: diff --git a/server/src/bid_doctor/project/service.py b/server/src/bid_doctor/project/service.py index 7924db7..e15e362 100644 --- a/server/src/bid_doctor/project/service.py +++ b/server/src/bid_doctor/project/service.py @@ -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") diff --git a/server/src/bid_doctor/worker.py b/server/src/bid_doctor/worker.py index 39bd120..717a0e5 100644 --- a/server/src/bid_doctor/worker.py +++ b/server/src/bid_doctor/worker.py @@ -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) diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 1477a2a..e46c809 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -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) diff --git a/server/tests/test_billing_flow.py b/server/tests/test_billing_flow.py new file mode 100644 index 0000000..3dea98b --- /dev/null +++ b/server/tests/test_billing_flow.py @@ -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 diff --git a/server/tests/test_ops_console.py b/server/tests/test_ops_console.py new file mode 100644 index 0000000..29dc95f --- /dev/null +++ b/server/tests/test_ops_console.py @@ -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" + )