feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 15:06:06 +08:00
parent 54cbb556d0
commit dd8c04001d
78 changed files with 5064 additions and 104 deletions

View File

@@ -20,3 +20,12 @@
# BD_AI_MODEL=deepseek-chat # BD_AI_MODEL=deepseek-chat
# 按能力选模型(选填,JSON):没配的能力用 BD_AI_MODEL # 按能力选模型(选填,JSON):没配的能力用 BD_AI_MODEL
# BD_AI_MODELS={"reasoning": "deepseek-reasoner"} # 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"]

View File

@@ -45,6 +45,7 @@ jobs:
POSTGRES_USER: bd_owner POSTGRES_USER: bd_owner
POSTGRES_DB: bid_doctor POSTGRES_DB: bid_doctor
BD_APP_PASSWORD: dev-app BD_APP_PASSWORD: dev-app
BD_OPS_PASSWORD: dev-ops
run: sh deploy/compose/postgres/init-roles.sh run: sh deploy/compose/postgres/init-roles.sh
- working-directory: server - working-directory: server
run: | run: |

View File

@@ -81,7 +81,7 @@
骨架见技术架构 §13。后端 Python 3.12+(uv workspace),前端 Vue 3 + Vite(pnpm workspace)。 骨架见技术架构 §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/ src/bid_doctor/
core/ 配置、数据库与租户事务、错误码、加密、对象存储、队列、事件流、权限点 core/ 配置、数据库与租户事务、错误码、加密、对象存储、队列、事件流、权限点
identity/ billing/ project/ document/ checklist/ ai/ audit/ notification/ 业务模块(架构 §3.2) identity/ billing/ project/ document/ checklist/ ai/ audit/ notification/ 业务模块(架构 §3.2)
@@ -89,6 +89,7 @@ server/ 后端:api 和 worker 是同一个包、两种进程
migrations/ Alembic;sql/0001_baseline/ 是 data-model 的建表脚本,按模块拆分 migrations/ Alembic;sql/0001_baseline/ 是 data-model 的建表脚本,按模块拆分
tests/ 集成测试(真实 PostgreSQL + 行级安全策略)和单元测试;fixtures/ 是虚构的小样例 tests/ 集成测试(真实 PostgreSQL + 行级安全策略)和单元测试;fixtures/ 是虚构的小样例
apps/web/ 用户端单页应用 apps/web/ 用户端单页应用
apps/admin/ 运营后台(单独构建、单独部署,私有化不交付;接口见 docs/architecture/ops-api.md)
packages/api-client/ 由 docs/architecture/openapi.yaml 生成的类型 + 客户端(不手写接口类型) packages/api-client/ 由 docs/architecture/openapi.yaml 生成的类型 + 客户端(不手写接口类型)
prompts/ 带版本号的提示词(已发布的版本不再修改) prompts/ 带版本号的提示词(已发布的版本不再修改)
deploy/compose/ Docker Compose;deploy/docker/ 镜像 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 migrate` |
| 起后端 | `cd server && ../.venv/bin/bid-doctor api`(:8009),另开一个终端 `../.venv/bin/bid-doctor worker` | | 起后端 | `cd server && ../.venv/bin/bid-doctor api`(:8009),另开一个终端 `../.venv/bin/bid-doctor worker` |
| 起前端 | `pnpm dev`(:5173,`/api` 代理到 :8009)。dev 环境短信验证码固定是 `000000` | | 起前端 | `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` | | 后端检查 | `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` | | 前端检查 | `pnpm typecheck && pnpm build` |
| 改了 openapi.yaml 之后 | `pnpm gen:api`,重新生成的 `schema.d.ts` 一起提交(CI 会比对) | | 改了 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(协作权限、资产库)。

13
apps/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>标博士运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/admin/package.json Normal file
View File

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

53
apps/admin/src/App.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
/** 运营后台的框架:左侧菜单(OpsSidebar)+ 内容。登录页不带框架。菜单上的待办数随切换页面刷新,另外每分钟刷新一次。 */
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import { CHANGED, ops, type Operator, type Summary } from "./api";
import OpsSidebar from "./OpsSidebar.vue";
const route = useRoute();
const router = useRouter();
const me = ref<Operator | null>(null);
const summary = ref<Summary | null>(null);
async function loadMe() {
if (route.meta.bare) return;
me.value = await ops<Operator>("/me").catch(() => null);
}
async function loadSummary() {
if (route.meta.bare || !me.value) return;
summary.value = await ops<Summary>("/summary").catch(() => summary.value);
}
let timer: number | undefined;
onMounted(async () => {
await loadMe();
await loadSummary();
timer = window.setInterval(loadSummary, 60_000);
window.addEventListener(CHANGED, loadSummary);
});
onBeforeUnmount(() => {
window.clearInterval(timer);
window.removeEventListener(CHANGED, loadSummary);
});
watch(
() => route.fullPath,
async (_, old) => {
if (route.meta.bare) return;
if (!me.value || old?.startsWith("/login")) await loadMe();
await loadSummary();
},
);
async function logout() {
await ops("/auth/logout", { method: "POST" }).catch(() => undefined);
me.value = null;
summary.value = null;
await router.replace({ name: "login" });
}
</script>
<template>
<RouterView v-if="route.meta.bare" />
<div v-else class="ops-frame">
<OpsSidebar :me="me" :summary="summary" @logout="logout" />
<main class="ops-main"><div class="content"><RouterView /></div></main>
</div>
</template>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* 运营后台的左侧菜单:标准横版 logo(反白)+「运营后台」+ 环境标签(不是生产环境时醒目提示,免得把测试当生产);
* 分组像文件夹一样可以展开收起(记在本机),当前页所在的分组总是展开;菜单项带图标和待办数;底部是当前运营人员和退出。
* 菜单按权限点过滤(nav.ts)。
*/
import { ChevronDown, LogOut } from "@lucide/vue";
import logoUrl from "@design/logo/svg/logo-horizontal-reverse.svg?url";
import { computed, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import type { Operator, Summary } from "./api";
import { visibleNav } from "./nav";
const props = defineProps<{ me: Operator | null; summary: Summary | null }>();
const emit = defineEmits<{ logout: [] }>();
const route = useRoute();
const groups = computed(() => visibleNav(props.me?.permissions ?? []));
const KEY = "ops.nav.collapsed";
function load(): string[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? "[]") as string[];
} catch {
return [];
}
}
const collapsed = ref(new Set(load()));
function save(s: Set<string>) {
collapsed.value = s;
try {
localStorage.setItem(KEY, JSON.stringify([...s]));
} catch {
/* 隐私模式等存不了:只是这次有效 */
}
}
function toggle(key: string) {
const s = new Set(collapsed.value);
if (s.has(key)) s.delete(key);
else s.add(key);
save(s);
}
const isActive = (name: string, also: string[] = []) => route.name === name || also.includes(String(route.name));
const activeGroup = computed(() => groups.value.find((g) => g.items.some((i) => isActive(i.name, i.also)))?.key);
const isOpen = (key: string) => !collapsed.value.has(key);
// 跳到一个收起的分组里的页面(链接、详情页):把那一组展开
watch(activeGroup, (g) => {
if (g && collapsed.value.has(g)) save(new Set([...collapsed.value].filter((k) => k !== g)));
}, { immediate: true });
const ENV: Record<string, string> = { dev: "开发环境", test: "测试环境" };
</script>
<template>
<aside class="ops-side" aria-label="运营后台菜单">
<RouterLink class="brand" :to="{ name: 'requests' }" aria-label="标博士运营后台">
<img :src="logoUrl" alt="标博士" />
</RouterLink>
<div class="sub">
<span>运营后台</span>
<span v-if="me && me.env !== 'prod'" class="env">{{ ENV[me.env] ?? me.env }}</span>
</div>
<nav class="groups">
<section v-for="g in groups" :key="g.key" class="group">
<button
type="button"
class="gh"
:aria-expanded="isOpen(g.key)"
:aria-controls="`grp-${g.key}`"
@click="toggle(g.key)"
>
<span>{{ g.label }}</span>
<ChevronDown class="chev" :class="{ shut: !isOpen(g.key) }" :stroke-width="1.75" />
</button>
<ul v-show="isOpen(g.key)" :id="`grp-${g.key}`">
<li v-for="i in g.items" :key="i.name">
<RouterLink :to="{ name: i.name }" :aria-current="isActive(i.name, i.also) ? 'page' : undefined">
<component :is="i.icon" class="ic" :stroke-width="1.5" />
<span class="lbl">{{ i.label }}</span>
<span v-if="i.badge && summary && summary[i.badge]" class="badge" :class="{ warn: i.badge === 'failed_jobs_7d' }">
{{ summary[i.badge] }}
</span>
</RouterLink>
</li>
</ul>
</section>
</nav>
<div v-if="me" class="me">
<span class="avatar" aria-hidden="true">{{ me.display_name.slice(0, 1) }}</span>
<span class="who">
<b :title="me.display_name">{{ me.display_name }}</b>
<small>{{ me.role_label }} · {{ me.username }}</small>
</span>
<button type="button" class="out" aria-label="退出登录" title="退出登录" @click="emit('logout')">
<LogOut class="ic" :stroke-width="1.5" />
</button>
</div>
</aside>
</template>
<style scoped>
.ops-side {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--sp-5) var(--sp-3) var(--sp-4);
background: linear-gradient(180deg, var(--side-top) 0%, var(--side-bottom) 100%);
color: var(--side-fg);
overflow-y: auto;
}
.brand {
display: block;
padding: 0 var(--sp-2);
border-radius: var(--radius-ctl);
}
.brand img {
display: block;
width: 148px;
height: auto;
}
.brand:focus-visible,
.gh:focus-visible,
.groups a:focus-visible,
.out:focus-visible {
outline: 2px solid var(--side-fg-strong);
outline-offset: -2px;
}
.sub {
display: flex;
gap: var(--sp-2);
align-items: center;
margin: var(--sp-3) var(--sp-2) var(--sp-5);
padding-bottom: var(--sp-4);
border-bottom: var(--line) solid var(--side-line);
font-size: var(--fs-caption);
letter-spacing: 0.12em;
color: var(--side-muted);
}
/* 非生产环境:赭色实底标签(红色只给致命问题,U1) */
.env {
padding: 0 6px;
border-radius: var(--radius-xs);
background: var(--warn);
color: var(--c-ink);
font-weight: var(--fw-bold);
letter-spacing: 0;
line-height: 18px;
}
.groups {
flex: 1;
}
.group + .group {
margin-top: var(--sp-3);
}
.gh {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 28px;
padding: 0 var(--sp-2);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
font-size: var(--fs-caption);
color: var(--side-muted);
cursor: pointer;
}
.gh:hover {
color: var(--side-fg-strong);
}
.chev {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.chev.shut {
transform: rotate(-90deg);
}
ul {
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.groups a {
display: flex;
gap: var(--sp-3);
align-items: center;
height: 38px;
padding: 0 var(--sp-2) 0 var(--sp-3);
border-radius: var(--radius-ctl);
color: var(--side-fg);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.groups a .ic {
flex: none;
width: 18px;
height: 18px;
}
.groups a .lbl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groups a:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.groups a[aria-current="page"] {
background: var(--side-active);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.badge {
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--side-avatar-bg);
color: var(--side-fg-strong);
font: var(--fw-medium) var(--fs-caption) / 20px var(--font-figures);
text-align: center;
}
.badge.warn {
background: var(--warn);
color: var(--c-ink);
}
.me {
display: flex;
gap: var(--sp-2);
align-items: center;
margin-top: var(--sp-4);
padding: var(--sp-3) var(--sp-2) 0;
border-top: var(--line) solid var(--side-line);
}
.avatar {
display: inline-grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--side-avatar-bg);
box-shadow: 0 0 0 1px var(--side-avatar-ring);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.who {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.who b {
overflow: hidden;
color: var(--side-fg-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.who small {
font-size: var(--fs-caption);
color: var(--side-muted);
}
.out {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius-ctl);
background: none;
color: var(--side-fg);
cursor: pointer;
}
.out:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.ic {
width: 18px;
height: 18px;
}
</style>

15
apps/admin/src/admin.css Normal file
View File

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

155
apps/admin/src/api.ts Normal file
View File

@@ -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<T>(path: string, init: { method?: string; body?: unknown; query?: Record<string, string | undefined> } = {}): Promise<T> {
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<string, Record<string, number>>;
};
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<string, string>;
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<string, string> | 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<string, string> = {
submitted: "等待转账",
paid: "已到账",
activated: "已开通",
rejected: "已退回",
cancelled: "已撤回",
};
export const QUOTA_KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣",
};
export const JOB_KIND: Record<string, string> = { parse_job: "解析", check_run: "检查", report_job: "报告" };

11
apps/admin/src/main.ts Normal file
View File

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

49
apps/admin/src/nav.ts Normal file
View File

@@ -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);
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 审计(FR-M6-07):谁在什么时候对什么做了什么。修改前后的内容是业务内容,这里不显示;删除项目后只剩动作和时间。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, ops, OpsError, type AuditEvent } from "../api";
const action = ref("");
const items = ref<AuditEvent[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: AuditEvent[] }>("/audit-events", { query: { action: action.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const ACTOR: Record<string, string> = { user: "用户", ops: "运营", system: "系统" };
</script>
<template>
<div class="page-title">
<div>
<h1>审计</h1>
<p>最近 200 条。按动作前缀筛选,如 ops.(运营操作)、project.delete、tenant.close。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="load">
<input v-model="action" class="input" placeholder="动作前缀,如 ops." aria-label="动作前缀" />
<button type="submit" class="btn">筛选</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>企业</th><th>操作人</th><th>动作</th><th>对象</th><th></th></tr></thead>
<tbody>
<tr v-for="e in items" :key="e.id">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: e.tenant.id } }">{{ e.tenant.name }}</RouterLink></td>
<td>{{ ACTOR[e.actor_kind] ?? e.actor_kind }}<template v-if="e.actor_name"> · {{ e.actor_name }}</template></td>
<td class="num">{{ e.action }}</td>
<td class="cap muted">{{ e.object_type }} {{ e.object_id?.slice(0, 8) ?? "" }}</td>
<td class="cap muted">{{ e.scrubbed ? "内容已抹掉" : "" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6" class="muted">没有记录</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/** 失败任务:解析、检查、报告。错误类别、错误码、阶段、耗时、模型调用次数;不含文档内容。失败的都已自动退回额度(红线 9)。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, JOB_KIND, ops, OpsError, type FailedJob } from "../api";
const items = ref<FailedJob[]>([]);
const err = ref("");
onMounted(async () => {
try {
items.value = (await ops<{ items: FailedJob[] }>("/failed-jobs")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
});
const CLASS: Record<string, string> = { input: "文件问题", infra: "系统异常", cancelled: "取消" };
</script>
<template>
<div class="page-title">
<div>
<h1>失败任务</h1>
<p>失败的任务都已自动退回额度。系统异常(infra)要排查,文件问题(input)一般是用户的文件读不了。</p>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>任务</th><th>企业</th><th>阶段</th><th>错误</th><th class="r">耗时</th><th class="r">模型调用</th><th>编号</th></tr></thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td class="num nowrap">{{ fmt(j.created_at) }}</td>
<td>{{ JOB_KIND[j.kind] ?? j.kind }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: j.tenant.id } }">{{ j.tenant.name }}</RouterLink></td>
<td>{{ j.stage ?? "—" }}</td>
<td>
<span :class="{ 'err-text': j.error_class === 'infra' }">{{ CLASS[j.error_class ?? ""] ?? j.error_class ?? "" }}</span> {{ j.error_code ?? "" }}
<p v-if="j.error_detail" class="inv">{{ j.error_detail }}</p>
</td>
<td class="num r">{{ j.duration_s != null ? `${Math.round(j.duration_s)} 秒` : "—" }}</td>
<td class="num r">{{ j.ai_calls }}</td>
<td class="num cap muted">{{ j.id.slice(0, 8) }}</td>
</tr>
<tr v-if="!items.length"><td colspan="8" class="muted">没有失败的任务</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* 误报 / 漏报反馈(J5):逐条复盘,再迭代规则。不显示招标 / 投标原文(决策 M6-7),只给标题、理由、规则编号、
* 提示词版本、页码;要看原文请用户导出报告发过来。致命问题「确认无误」写的说明也会自动进来(U4)。
*/
import { onMounted, ref, watch } from "vue";
import { fmt, ops, OpsError, type Feedback } from "../api";
const kind = ref<"" | "false_positive" | "false_negative">("");
const items = ref<Feedback[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: Feedback[] }>("/feedback", { query: { kind: kind.value || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
watch(kind, load);
const SEV: Record<string, string> = { fatal: "致命", important: "重要", hint: "提示" };
</script>
<template>
<div class="page-title">
<div>
<h1>误报 / 漏报</h1>
<p>只看标题、理由和规则,不看原文。这里的数字只给内部复盘用,界面上不对用户展示统计。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="kind === ''" @click="kind = ''">全部</button>
<button type="button" :aria-pressed="kind === 'false_positive'" @click="kind = 'false_positive'">误报</button>
<button type="button" :aria-pressed="kind === 'false_negative'" @click="kind = 'false_negative'">漏报</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th>企业</th><th>条目</th><th>用户写的</th><th>规则 / 版本</th><th>页码</th></tr></thead>
<tbody>
<tr v-for="f in items" :key="f.id">
<td class="num nowrap">{{ fmt(f.created_at) }}</td>
<td>{{ f.kind === "false_positive" ? "误报" : "漏报" }}<p v-if="f.source !== 'user'" class="inv">{{ f.source }}</p></td>
<td>{{ f.tenant_name }}</td>
<td>
<span v-if="f.severity" class="tag">{{ SEV[f.severity] ?? f.severity }}</span> {{ f.title ?? "(条目已删除)" }}
<p class="inv">{{ f.object_type === "finding" ? "查标问题" : "读标清单项" }}<template v-if="f.finding_type"> · {{ f.finding_type }}</template><template v-if="f.detector"> · {{ f.detector === "ai" ? "模型" : "规则" }}</template></p>
</td>
<td>{{ f.note ?? "—" }}</td>
<td>
<p class="inv">{{ f.rule_code ?? "—" }}</p>
<p class="inv">{{ f.rule_set_version ?? "" }}<template v-if="f.prompt_versions"> · {{ Object.entries(f.prompt_versions).map(([k, v]) => `${k} ${v}`).join("、") }}</template></p>
</td>
<td class="num">{{ f.pages.join("、") || "—" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">还没有反馈</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/** 运营登录(决策 M6-4):用户名 + 密码 + 身份验证器 App 上的 6 位动态码。账号用命令行建(bid-doctor ops-admin create)。 */
import logoUrl from "@design/logo/svg/logo-horizontal.svg?url";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ops, OpsError } from "../api";
const router = useRouter();
const username = ref("");
const password = ref("");
const totp = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
if (busy.value) return;
busy.value = true;
err.value = "";
try {
await ops("/auth/login", { method: "POST", body: { username: username.value.trim(), password: password.value, totp: totp.value.trim() } });
await router.replace({ name: "requests" });
} catch (e) {
err.value = e instanceof OpsError ? e.message : "登录失败";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="login">
<form class="card" novalidate @submit.prevent="submit">
<img class="logo" :src="logoUrl" alt="标博士" />
<h1>运营后台</h1>
<p class="cap muted">只在内网和白名单地址可用,所有操作都会留痕。</p>
<div class="field fill">
<label for="u">用户名</label>
<input id="u" v-model="username" class="input" autocomplete="username" />
</div>
<div class="field fill">
<label for="p">密码</label>
<input id="p" v-model="password" class="input" type="password" autocomplete="current-password" />
</div>
<div class="field fill">
<label for="t">动态验证码</label>
<input id="t" v-model="totp" class="input" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="身份验证器 App 上的 6 位数字" />
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<button type="submit" class="btn primary" :disabled="busy">登录</button>
</form>
</div>
</template>
<style scoped>
.login {
display: grid;
place-items: center;
min-height: 100vh;
}
.login form {
width: 380px;
padding: var(--sp-8);
}
.logo {
display: block;
width: 168px;
margin-bottom: var(--sp-6);
}
h1 {
margin: 0;
font: var(--fw-bold) var(--fs-h1) / var(--lh-h1) var(--font-serif);
}
.field {
margin-top: var(--sp-4);
}
.btn {
width: 100%;
margin-top: var(--sp-6);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* 开通申请(G8):用户在线提交 → 对公转账 → 这里「标记到账」→「开通」(默认专业版 1 年;当前专业版没到期时是续费,新一期排队)
* → 开发票后登记发票号。不通过就「退回」并写原因,用户在额度页看得到。每一步都写审计。
*/
import { computed, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, notifyChanged, ops, OpsError, REQUEST_STATUS, type ActivationRequest } from "../api";
const filter = ref<"open" | "all">("open");
const items = ref<ActivationRequest[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: ActivationRequest[] }>("/activation-requests")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const rows = computed(() => (filter.value === "open" ? items.value.filter((r) => r.status === "submitted" || r.status === "paid") : items.value));
const acting = ref<{ id: string; kind: "reject" | "invoice" | "activate" } | null>(null);
const input = ref("");
const months = ref(12);
const busy = ref(false);
function start(r: ActivationRequest, kind: "reject" | "invoice" | "activate") {
acting.value = { id: r.id, kind };
input.value = "";
months.value = 12;
}
async function act(r: ActivationRequest, kind: "paid" | "activate" | "reject" | "invoice") {
busy.value = true;
err.value = "";
try {
const body = kind === "reject" ? { note: input.value.trim() } : kind === "invoice" ? { invoice_no: input.value.trim() } : kind === "activate" ? { months: months.value } : undefined;
await ops(`/activation-requests/${r.id}/${kind}`, { method: "POST", body });
acting.value = null;
notifyChanged();
await load();
} catch (e) {
err.value = e instanceof OpsError ? `${r.request_no}:${e.message}` : "操作失败";
} finally {
busy.value = false;
}
}
const INVOICE_TYPE: Record<string, string> = { general: "普票", special: "专票" };
</script>
<template>
<div class="page-title">
<div>
<h1>开通申请</h1>
<p>核对对公转账到账后再开通。转账备注应当是申请单号。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="filter === 'open'" @click="filter = 'open'">待处理</button>
<button type="button" :aria-pressed="filter === 'all'" @click="filter = 'all'">全部</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>申请单号</th><th>企业</th><th>发票信息</th><th>状态</th><th>提交时间</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ r.request_no }}</td>
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: r.tenant.id } }">{{ r.tenant.name }}</RouterLink>
<p class="inv">申请人 {{ r.applicant_phone_hint ?? "—" }}</p>
</td>
<td>
<p class="inv">
{{ r.invoice_info.title }} · {{ INVOICE_TYPE[r.invoice_info.invoice_type ?? "general"] }}<br />
税号 {{ r.invoice_info.tax_no }}<br />
<template v-if="r.invoice_info.address_phone">{{ r.invoice_info.address_phone }}<br /></template>
<template v-if="r.invoice_info.bank_account">{{ r.invoice_info.bank_account }}<br /></template>
联系人 {{ r.invoice_info.contact_name }} {{ r.invoice_info.contact_phone }}
</p>
</td>
<td>
<span class="tag">{{ REQUEST_STATUS[r.status] }}</span>
<p v-if="r.ops_note" class="inv">原因:{{ r.ops_note }}</p>
<p v-if="r.invoice_no" class="inv">发票 {{ r.invoice_no }}</p>
</td>
<td class="num nowrap">{{ fmt(r.created_at) }}</td>
<td>
<div v-if="acting?.id === r.id" class="acts">
<template v-if="acting.kind === 'activate'">
<label class="cap">周期(月)<input v-model.number="months" class="input sm m" type="number" min="1" max="36" /></label>
<button type="button" class="btn sm primary" :disabled="busy" @click="act(r, 'activate')">确认开通</button>
</template>
<template v-else>
<input v-model="input" class="input sm" :placeholder="acting.kind === 'reject' ? '退回原因(用户看得到)' : '发票号'" />
<button type="button" class="btn sm primary" :disabled="busy || input.trim().length < 2" @click="act(r, acting.kind)">确认</button>
</template>
<button type="button" class="btn sm text" @click="acting = null">取消</button>
</div>
<div v-else class="acts">
<button v-if="r.status === 'submitted'" type="button" class="btn sm" :disabled="busy" @click="act(r, 'paid')">标记到账</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm primary" @click="start(r, 'activate')">开通</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm text" @click="start(r, 'reject')">退回</button>
<button v-if="r.status === 'activated' && !r.invoice_no" type="button" class="btn sm" @click="start(r, 'invoice')">登记发票</button>
</div>
</td>
</tr>
<tr v-if="!rows.length"><td colspan="6" class="muted">{{ filter === "open" ? "没有待处理的申请" : "还没有申请" }}</td></tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.input.m {
width: 64px;
margin-left: var(--sp-1);
}
.hint-warn {
margin: 0 0 var(--sp-4);
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
/**
* 企业详情:成员(手机号脱敏)、套餐历史、额度流水(不带项目名称)、任务统计;调额度(G6:原因必填,不能扣成负数)。
* 不显示项目名称和任何业务内容(m6-plan §2.2)。
*/
import { onMounted, ref } from "vue";
import { day, fmt, JOB_KIND, ops, OpsError, QUOTA_KIND, type TenantDetail } from "../api";
const props = defineProps<{ id: string }>();
const t = ref<TenantDetail | null>(null);
const err = ref("");
async function load() {
try {
t.value = await ops<TenantDetail>(`/tenants/${props.id}`);
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const amount = ref<number | null>(null);
const reason = ref("");
const msg = ref("");
const busy = ref(false);
async function adjust() {
if (!amount.value || reason.value.trim().length < 2) {
msg.value = "填写数量(可以是负数)和原因";
return;
}
busy.value = true;
msg.value = "";
try {
await ops(`/tenants/${props.id}/quota-adjustments`, { method: "POST", body: { unit: "parse", amount: amount.value, reason: reason.value.trim() } });
amount.value = null;
reason.value = "";
msg.value = "已调整";
await load();
} catch (e) {
msg.value = e instanceof OpsError ? e.message : "调整失败";
} finally {
busy.value = false;
}
}
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
const SUB: Record<string, string> = { scheduled: "排队中", active: "生效中", expired: "已过期", cancelled: "已结束" };
const JOB_STATUS: Record<string, string> = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败", cancelled: "取消" };
</script>
<template>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<template v-if="t">
<div class="page-title">
<div>
<h1>{{ t.name }}</h1>
<p>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}<template v-if="t.plan.ends_at"> · 至 {{ day(t.plan.ends_at) }}</template><template v-if="t.plan.expired">(已到期)</template>
· 注册 {{ day(t.created_at) }} · 项目 {{ t.projects }} 个<template v-if="t.status === 'closed'"> · 已注销</template>
</p>
</div>
</div>
<div class="grid">
<section class="card">
<div class="card-h"><h2>成员</h2></div>
<table class="data">
<thead><tr><th>姓名</th><th>手机号</th><th>角色</th><th>最近登录</th></tr></thead>
<tbody>
<tr v-for="(m, i) in t.members" :key="i">
<td>{{ m.display_name }}</td><td class="num">{{ m.phone_hint ?? "—" }}</td><td>{{ m.role === "owner" ? "所有者" : m.role }}</td>
<td class="num">{{ fmt(m.last_login_at) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<div class="card-h"><h2>调额度</h2><span class="cap muted r">解析余额 {{ t.balance ?? 0 }}</span></div>
<form class="adj" @submit.prevent="adjust">
<input v-model.number="amount" class="input" type="number" placeholder="数量,如 20 或 -5" aria-label="数量" />
<input v-model="reason" class="input" maxlength="200" placeholder="原因(必填,写进流水,用户看得到)" aria-label="原因" />
<button type="submit" class="btn primary" :disabled="busy">调整</button>
</form>
<p v-if="msg" class="cap muted">{{ msg }}</p>
<div class="card-h sub"><h2>任务</h2></div>
<p v-for="(st, kind) in t.jobs" :key="kind" class="cap">
{{ JOB_KIND[kind] ?? kind }}:<template v-for="(n, s, i) in st" :key="s">{{ i ? " · " : "" }}{{ JOB_STATUS[s] ?? s }} {{ n }}</template>
</p>
<p v-if="!Object.keys(t.jobs).length" class="cap muted">还没有任务</p>
</section>
</div>
<section class="card flush">
<div class="card-h"><h2>套餐</h2></div>
<table class="data">
<thead><tr><th>套餐</th><th>状态</th><th>来源</th><th>开始</th><th>结束</th><th>申请单</th></tr></thead>
<tbody>
<tr v-for="(s, i) in t.subscriptions" :key="i">
<td>{{ PLAN[s.plan_code] ?? s.plan_code }}</td><td>{{ SUB[s.status] ?? s.status }}</td>
<td>{{ s.source === "signup" ? "注册" : s.source === "manual" ? "运营开通" : s.source }}</td>
<td class="num">{{ day(s.starts_at) }}</td><td class="num">{{ day(s.ends_at) }}</td><td class="num">{{ s.request_no ?? "—" }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush">
<div class="card-h"><h2>最近的额度流水</h2></div>
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th class="r">数量</th><th>任务</th><th>说明</th></tr></thead>
<tbody>
<tr v-for="(e, i) in t.quota_entries" :key="i">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td>{{ QUOTA_KIND[e.kind] ?? e.kind }}<span v-if="e.by_ops" class="cap muted">(运营)</span></td>
<td class="num r">{{ e.amount > 0 ? `+${e.amount}` : e.amount }}</td>
<td>{{ e.ref_type ? JOB_KIND[e.ref_type] : "—" }}</td>
<td class="muted">{{ e.reason === "recheck_24h" ? "24 小时内复查" : e.reason ?? "" }}</td>
</tr>
</tbody>
</table>
</section>
</template>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--gap-card);
}
.card-h .r {
margin-left: auto;
}
.card-h.sub {
margin-top: var(--sp-6);
}
.adj {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-2);
}
p.cap {
margin: var(--sp-2) 0 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 企业查询:企业名称(模糊)、手机号(精确,按 HMAC 查,库里没有明文)、申请单号。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { day, ops, OpsError, type Tenant } from "../api";
const q = ref("");
const items = ref<Tenant[]>([]);
const err = ref("");
async function search() {
err.value = "";
try {
items.value = (await ops<{ items: Tenant[] }>("/tenants", { query: { q: q.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(search);
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
</script>
<template>
<div class="page-title">
<div>
<h1>企业</h1>
<p>只看账号、套餐和用量,不看项目和文件内容。查看详情会留痕。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="search">
<input v-model="q" class="input" placeholder="企业名称、手机号或申请单号(KT-…)" aria-label="搜索企业" />
<button type="submit" class="btn">搜索</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>企业</th><th>手机号</th><th>套餐</th><th class="r">解析余额</th><th class="r">项目</th><th>注册</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id">
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: t.id } }">{{ t.name }}</RouterLink>
<span v-if="t.open_requests" class="tag">待处理申请 {{ t.open_requests }}</span>
</td>
<td class="num">{{ t.phone_hint ?? "—" }}</td>
<td>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}
<span v-if="t.plan.expired" class="err-text">已到期</span>
<span v-else-if="t.plan.ends_at" class="cap muted">至 {{ day(t.plan.ends_at) }}</span>
</td>
<td class="num r">{{ t.balance ?? "—" }}</td>
<td class="num r">{{ t.projects }}</td>
<td class="num">{{ day(t.created_at) }}</td>
<td>{{ t.status === "closed" ? "已注销" : t.status === "active" ? "正常" : t.status }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">没有找到</td></tr>
</tbody>
</table>
</section>
</template>

20
apps/admin/src/router.ts Normal file
View File

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

20
apps/admin/tsconfig.json Normal file
View File

@@ -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"]
}

25
apps/admin/vite.config.ts Normal file
View File

@@ -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 } },
},
});

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* 专业版开通 / 续费申请(原型 quota.html 的续费对话框,G8,决策 M6-6):
* 提交申请不扣额度、不用先付款;之后对公转账,运营确认到账后开通并开发票。页面不写价格(D2 未定),运营联系确认金额。
* 专票要地址电话和开户行账号。
*/
import { unwrap } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { ref, watch } from "vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { useMe, type ActivationRequest } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ renew?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ submitted: [r: ActivationRequest] }>();
const qc = useQueryClient();
const me = useMe();
const title = ref("");
const type = ref<"general" | "special">("general");
const taxNo = ref("");
const addressPhone = ref("");
const bankAccount = ref("");
const contact = ref("");
const phone = ref("");
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
watch(open, (v) => {
if (!v) return;
title.value = me.data.value?.tenant.name ?? "";
contact.value = me.data.value?.user.display_name ?? "";
errs.value = {};
err.value = "";
});
function validate(): boolean {
const e: Record<string, string> = {};
if (title.value.trim().length < 2) e.title = "请填写发票抬头,一般就是企业名称";
const t = taxNo.value.trim().toUpperCase();
if (!t) e.taxNo = "请填写纳税人识别号,开发票要用";
else if (!/^[0-9A-Z]{15,20}$/.test(t)) e.taxNo = "纳税人识别号是 15 到 20 位数字或大写字母,请核对";
if (type.value === "special" && !addressPhone.value.trim()) e.addressPhone = "增值税专用发票要填地址电话";
if (type.value === "special" && !bankAccount.value.trim()) e.bankAccount = "增值税专用发票要填开户行和账号";
if (!contact.value.trim()) e.contact = "请填写联系人";
if (!/^1[3-9]\d{9}$/.test(phone.value.trim())) e.phone = "手机号应为 11 位数字";
errs.value = e;
return !Object.keys(e).length;
}
async function submit() {
if (busy.value || !validate()) return;
busy.value = true;
err.value = "";
try {
const r = await unwrap(
api.POST("/activation-requests", {
body: {
plan_code: "pro",
invoice_info: {
title: title.value.trim(),
tax_no: taxNo.value.trim().toUpperCase(),
invoice_type: type.value,
address_phone: addressPhone.value.trim() || undefined,
bank_account: bankAccount.value.trim() || undefined,
contact_name: contact.value.trim(),
contact_phone: phone.value.trim(),
},
},
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
open.value = false;
toast(`已提交${props.renew ? "续费" : "开通"}申请 ${r.request_no}`);
emit("submitted", r);
} catch (e) {
err.value = isApiError(e, "activation_pending") ? "已经有一份待处理的申请,等它处理完再提交" : errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">{{ renew ? "续费专业版" : "开通专业版" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<p class="lead">
专业版按年{{ renew ? "续费" : "开通" }}:提交申请 → 对公转账 → 运营确认到账后开通,并开发票。<b>提交申请不扣额度,也不用先付款</b>;金额由运营联系你确认。
</p>
<div class="field fill" :class="{ err: errs.title }">
<label for="ad-title">发票抬头 <small>(必填)</small></label>
<input id="ad-title" v-model="title" class="input" maxlength="100" autocomplete="off" />
<span v-if="errs.title" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.title }}</span>
<span v-else class="msg">额度绑定注册的企业主体,不可转让</span>
</div>
<div class="grid2">
<div class="field">
<label for="ad-type">发票类型 <small>(必填)</small></label>
<SelectBox
id="ad-type"
:model-value="type"
:options="[
{ value: 'general', label: '增值税普通发票' },
{ value: 'special', label: '增值税专用发票' },
]"
@update:model-value="(v) => (type = v === 'special' ? 'special' : 'general')"
/>
</div>
<div class="field" :class="{ err: errs.taxNo }">
<label for="ad-tax">纳税人识别号 <small>(必填)</small></label>
<input id="ad-tax" v-model="taxNo" class="input num" maxlength="20" placeholder="18 位统一社会信用代码" autocomplete="off" />
<span v-if="errs.taxNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.taxNo }}</span>
</div>
</div>
<template v-if="type === 'special'">
<div class="field fill" :class="{ err: errs.addressPhone }">
<label for="ad-addr">地址、电话 <small>(必填)</small></label>
<input id="ad-addr" v-model="addressPhone" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.addressPhone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.addressPhone }}</span>
</div>
<div class="field fill" :class="{ err: errs.bankAccount }">
<label for="ad-bank">开户行及账号 <small>(必填)</small></label>
<input id="ad-bank" v-model="bankAccount" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.bankAccount" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.bankAccount }}</span>
</div>
</template>
<div class="grid2">
<div class="field" :class="{ err: errs.contact }">
<label for="ad-contact">联系人 <small>(必填)</small></label>
<input id="ad-contact" v-model="contact" class="input" maxlength="40" autocomplete="off" />
<span v-if="errs.contact" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.contact }}</span>
</div>
<div class="field" :class="{ err: errs.phone }">
<label for="ad-phone">手机号 <small>(必填)</small></label>
<input id="ad-phone" v-model="phone" class="input num" maxlength="11" inputmode="numeric" autocomplete="off" />
<span v-if="errs.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.phone }}</span>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<span class="cap muted left">不扣额度</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">提交申请</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lead {
margin: 0 0 var(--sp-4);
color: var(--text-secondary);
}
.lead b {
color: var(--text-primary);
font-weight: var(--fw-medium);
}
.grid2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.body > .field + .field,
.body > .field + .grid2,
.body > .grid2 + .field,
.body > .grid2 + .grid2 {
margin-top: var(--sp-4);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -3,11 +3,12 @@
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。 * 登录后的页面框架(设计规范 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 { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router"; import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url"; import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url";
import NotificationBell from "./NotificationBell.vue";
import Toasts from "./Toasts.vue"; import Toasts from "./Toasts.vue";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs"; import { provideCrumbs } from "@/lib/crumbs";
@@ -74,12 +75,15 @@ async function logout() {
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined"> <RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
<Award class="ic" :stroke-width="1.5" /><span>资质库</span> <Award class="ic" :stroke-width="1.5" /><span>资质库</span>
</RouterLink> </RouterLink>
<RouterLink :to="{ name: 'quota' }" :aria-current="route.name === 'quota' ? 'page' : undefined">
<Wallet class="ic" :stroke-width="1.5" /><span>额度</span>
</RouterLink>
</nav> </nav>
<div class="me"> <RouterLink class="me" :to="{ name: 'account' }" :aria-current="route.name === 'account' ? 'page' : undefined" aria-label="账号">
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span> <span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>
<b class="uname" :title="name">{{ name }}</b> <b class="uname" :title="name">{{ name }}</b>
<span v-if="plan" class="plan" :data-plan="planKey">{{ plan.name }}</span> <span v-if="plan" class="plan" :data-plan="planKey">{{ plan.name }}</span>
</div> </RouterLink>
</aside> </aside>
<main class="main"> <main class="main">
@@ -93,18 +97,25 @@ async function logout() {
</template> </template>
</div> </div>
<div class="top-right"> <div class="top-right">
<span v-if="accounts.length" class="quota-chip tip-below tip-left" tabindex="0" :data-tip="quotaTip" :aria-label="quotaTip"> <RouterLink v-if="accounts.length" class="quota-chip tip-below tip-left" :to="{ name: 'quota' }" :data-tip="quotaTip" :aria-label="quotaTip">
<template v-for="(a, i) in accounts" :key="a.unit"> <template v-for="(a, i) in accounts" :key="a.unit">
<span v-if="i > 0" class="sep"></span> <span v-if="i > 0" class="sep"></span>
<span class="q" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ quotaUnit(a.unit) }}<span class="fig">{{ a.balance }}</span></span> <span class="q" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ quotaUnit(a.unit) }}<span class="fig">{{ a.balance }}</span></span>
</template> </template>
</span> </RouterLink>
<NotificationBell />
<span class="vsep" aria-hidden="true"></span> <span class="vsep" aria-hidden="true"></span>
<button class="icon-btn tip-below tip-left" type="button" aria-label="退出登录" data-tip="退出登录" :disabled="loggingOut" @click="logout"> <button class="icon-btn tip-below tip-left" type="button" aria-label="退出登录" data-tip="退出登录" :disabled="loggingOut" @click="logout">
<LogOut class="ic lg" :stroke-width="1.5" /> <LogOut class="ic lg" :stroke-width="1.5" />
</button> </button>
</div> </div>
</header> </header>
<!-- 套餐到期后只读(决策 M6-5):每一页顶上都提醒,额度页里有续费入口 -->
<div v-if="plan?.read_only && route.name !== 'quota'" class="expired-bar" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已到期,数据只读:可以查看、下载已导出的报告,不能新建项目、解析和检查。</p>
<RouterLink class="btn sm" :to="{ name: 'quota' }">续费</RouterLink>
</div>
<RouterView /> <RouterView />
</main> </main>
<Toasts /> <Toasts />

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
/**
* 顶栏的通知(原型 app.js 的铃铛,m6-plan §1.6):有未读时带圆点;点开是最近 30 条,点一条跳到相关页面并标为已读。
* 解析 / 检查 / 报告完成、额度用到 80% / 100%、开通结果、到期提醒都在这里。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Bell } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useNotifications, type Notification } from "@/lib/queries";
const q = useNotifications();
const qc = useQueryClient();
const router = useRouter();
const open = ref(false);
const items = computed(() => q.data.value?.items ?? []);
const unread = computed(() => q.data.value?.unread_count ?? 0);
async function markRead(ids?: string[]) {
await unwrap(api.POST("/notifications/read", { body: ids ? { ids } : {} }));
void qc.invalidateQueries({ queryKey: qk.notifications });
}
async function go(n: Notification) {
open.value = false;
if (!n.read_at) void markRead([n.id]);
if (n.link) await router.push(n.link);
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
class="icon-btn tip-below tip-left bell"
type="button"
:aria-label="unread ? `通知(${unread} 条未读)` : '通知'"
:data-tip="open ? undefined : '通知'"
>
<Bell class="ic lg" :stroke-width="1.5" /><span v-if="unread" class="dot" aria-hidden="true"></span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="menu popper notes" align="end" :side-offset="6" aria-label="通知">
<div class="nh">
<b>通知</b>
<button v-if="unread" type="button" class="lnk cap" @click="markRead()">全部标为已读</button>
</div>
<p v-if="!items.length" class="cap muted none">还没有通知</p>
<ul v-else>
<li v-for="n in items" :key="n.id">
<button type="button" class="note" :class="{ unread: !n.read_at }" @click="go(n)">
<span class="t">{{ n.title }}</span>
<span class="cap muted">
<template v-if="n.project_name">{{ n.project_name }} · </template>{{ fmtDateTime(n.created_at) }}
</span>
</button>
</li>
</ul>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.bell {
position: relative;
}
.dot {
position: absolute;
top: 6px;
right: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-ink);
box-shadow: 0 0 0 2px var(--bg-page);
}
/* PopoverContent 的根元素上没有 scoped 属性(在 Reka 的定位外层上),从外层选进去 */
[data-reka-popper-content-wrapper] :deep(.notes) {
width: 360px;
max-height: min(480px, 70vh);
padding: 0;
}
.nh {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
border-bottom: var(--line) solid var(--border-subtle);
}
.none {
margin: 0;
padding: var(--sp-4);
}
ul {
margin: 0;
padding: var(--sp-1);
list-style: none;
}
.note {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
}
.note:hover {
background: var(--c-n100);
}
.note.unread {
color: var(--text-primary);
}
.note.unread .t {
font-weight: var(--fw-medium);
}
.note.unread .t::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: var(--sp-2);
border-radius: 50%;
background: var(--c-ink);
vertical-align: 2px;
}
</style>

View File

@@ -4,11 +4,14 @@
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。 * 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明; * 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
* 递交后项目只读,可以撤销,都留痕。 * 递交后项目只读,可以撤销,都留痕。
* 删除项目(J4,决策 M6-1):放在「更多」里,彻底删除、不能恢复,确认时要输入项目名称;额度流水和不含内容的审计保留。
*/ */
import { unwrap } from "@bid-doctor/api-client"; import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api"; import { api, errorTitle } from "@/lib/api";
import { qk } from "@/lib/keys"; 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(() => const checkTo = computed(() =>
p.value?.latest_check_run_id p.value?.latest_check_run_id
? { name: "results", params: { projectId: p.value.id, runId: 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(() =>
<dd> <dd>
<span class="tag">{{ projectStatus(p.status) }}</span> <span class="tag">{{ projectStatus(p.status) }}</span>
<button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button> <button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="askDelete">删除项目</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</dd> </dd>
</div> </div>
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined"> <div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
@@ -106,4 +149,19 @@ const checkTo = computed(() =>
<p>标记后项目改为只读,可以随时撤销。</p> <p>标记后项目改为只读,可以随时撤销。</p>
<p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p> <p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog v-model:open="delOpen" :title="`删除项目「${p?.name ?? ''}」?`" ok-text="删除项目" busy-text="正在删除…" :busy="busy" @ok="doDelete">
<p>项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,<b>不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">额度流水和一条不含业务内容的操作记录会保留。</p>
<div class="field fill del-name" :class="{ err: delErr }">
<label for="del-name">输入项目名称确认</label>
<input id="del-name" v-model="delName" class="input" :placeholder="p?.name" autocomplete="off" @input="delErr = ''" />
<span v-if="delErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ delErr }}</span>
</div>
</ConfirmDialog>
</template> </template>
<style scoped>
.del-name {
margin-top: var(--sp-3);
}
</style>

View File

@@ -37,6 +37,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
if (e.type === "job.finished") { if (e.type === "job.finished") {
// 结束后要拿完整结果(error、result),并刷新项目、额度、清单 // 结束后要拿完整结果(error、result),并刷新项目、额度、清单
void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) }); void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.projects }); void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota }); void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) { if (e.project_id) {
@@ -55,6 +56,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
); );
if (e.type === "job.finished") { if (e.type === "job.finished") {
void qc.invalidateQueries({ queryKey: qk.checkRun(jobId) }); 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.findings(jobId) });
void qc.invalidateQueries({ queryKey: qk.projects }); void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota }); void qc.invalidateQueries({ queryKey: qk.quota });
@@ -69,6 +71,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
switch (e.type) { switch (e.type) {
case "quota.changed": case "quota.changed":
void qc.invalidateQueries({ queryKey: qk.quota }); void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.notifications });
break; break;
case "project.changed": case "project.changed":
void qc.invalidateQueries({ queryKey: qk.projects }); void qc.invalidateQueries({ queryKey: qk.projects });

View File

@@ -2,6 +2,10 @@
export const qk = { export const qk = {
me: ["me"] as const, me: ["me"] as const,
quota: ["quota"] 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, plans: ["plans"] as const,
projects: ["projects"] as const, projects: ["projects"] as const,
project: (id: string) => ["project", id] as const, project: (id: string) => ["project", id] as const,

View File

@@ -1,6 +1,6 @@
/** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */ /** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */
import { unwrap, type Schemas } from "@bid-doctor/api-client"; 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 { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api"; import { api } from "./api";
import { qk } from "./keys"; 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)); 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() { export function usePlans() {
return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 }); return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 });
} }

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* 账号(m6-plan §1.5):企业名称、手机号(脱敏)、姓名、套餐;最下面注销账号(J4、data-model §6.2,决策 M6-8)。
* 注销:输入企业名称 + 短信验证码(发到自己的手机号)。全部项目、资质库彻底删除,剩余额度作废、不退款;
* 开通申请和发票记录按财务要求保留。注销后手机号可以重新注册。
*/
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { useMe, useQuota } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "账号" }]);
const me = useMe();
const quota = useQuota();
const m = computed(() => me.data.value);
const plan = computed(() => quota.data.value?.plan ?? m.value?.plan);
const paidLeft = computed(() => plan.value && plan.value.code !== "free" && !plan.value.read_only);
const open = ref(false);
const name = ref("");
const code = ref("");
const err = ref<{ name?: string; code?: string; other?: string }>({});
const busy = ref(false);
const wait = ref(0);
let timer: number | undefined;
onBeforeUnmount(() => window.clearInterval(timer));
function ask() {
name.value = "";
code.value = "";
err.value = {};
open.value = true;
}
async function sendCode() {
try {
const r = await unwrap(api.POST("/me/sms-codes", { body: { purpose: "close_tenant" } }));
wait.value = r.resend_after_s;
window.clearInterval(timer);
timer = window.setInterval(() => (wait.value = Math.max(0, wait.value - 1)), 1000);
toast(`验证码已发送到 ${m.value?.user.phone_hint ?? "你的手机"}`);
} catch (e) {
err.value = { other: errorTitle(e) };
}
}
const router = useRouter();
const qc = useQueryClient();
async function close() {
const e: typeof err.value = {};
if (name.value.trim() !== m.value?.tenant.name) e.name = "企业名称不一致,请照上面的名称输入";
if (!/^\d{6}$/.test(code.value.trim())) e.code = "请填写 6 位验证码";
err.value = e;
if (Object.keys(e).length) return;
busy.value = true;
try {
await unwrap(api.POST("/tenant/close", { body: { sms_code: code.value.trim(), confirm_tenant_name: name.value.trim() } }));
open.value = false;
qc.clear();
await router.replace({ name: "login" });
toast("账号已注销,业务数据正在后台删除");
} catch (x) {
if (isApiError(x, "sms_code_invalid")) err.value = { code: "验证码不正确或已过期" };
else if (isApiError(x, "tenant_name_mismatch")) err.value = { name: "企业名称不一致,请照上面的名称输入" };
else err.value = { other: errorTitle(x) };
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>账号</h1>
<p>一个企业一个账号;额度和数据都归属企业主体。</p>
</div>
</div>
<section class="card" aria-labelledby="h-info">
<div class="card-h"><h2 id="h-info">企业和登录</h2></div>
<dl v-if="m" class="kv">
<dt>企业名称</dt><dd>{{ m.tenant.name }}</dd>
<dt>手机号</dt><dd class="fig">{{ m.user.phone_hint || "—" }}</dd>
<dt>姓名</dt><dd>{{ m.user.display_name }}</dd>
<dt>套餐</dt>
<dd>
{{ plan?.name }}<template v-if="plan?.read_only">(已到期,只读)</template>
<RouterLink class="lnk cap" :to="{ name: 'quota' }">额度与套餐</RouterLink>
</dd>
</dl>
</section>
<section class="card danger" aria-labelledby="h-close">
<div class="card-h"><h2 id="h-close">注销账号</h2></div>
<p>注销后,企业下的全部项目(招标文件、投标文件、清单、检查结果、报告)和资质库都会<b>彻底删除,不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">开通申请和发票记录按财务要求保留;额度流水和不含业务内容的操作记录保留。注销后这个手机号可以重新注册。</p>
<button type="button" class="btn" @click="ask">注销账号</button>
</section>
</div>
<ConfirmDialog v-model:open="open" title="注销账号?" ok-text="注销账号" busy-text="正在注销…" :busy="busy" @ok="close">
<p>企业「{{ m?.tenant.name }}」下的全部项目和资质库都会彻底删除,<b>不能恢复</b>。</p>
<p v-if="paidLeft" class="warn-text">专业版还没到期(到 <span class="fig">{{ plan?.ends_at?.slice(0, 10) }}</span>),剩余额度作废,不退款。</p>
<div class="field fill" :class="{ err: err.name }">
<label for="cl-name">输入企业名称确认</label>
<input id="cl-name" v-model="name" class="input" :placeholder="m?.tenant.name" autocomplete="off" @input="err.name = undefined" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="field fill" :class="{ err: err.code }">
<label for="cl-code">短信验证码</label>
<div class="code-row">
<input id="cl-code" v-model="code" class="input num" maxlength="6" inputmode="numeric" autocomplete="one-time-code" @input="err.code = undefined" />
<button type="button" class="btn" :disabled="wait > 0" @click="sendCode">{{ wait > 0 ? `${wait} 秒后重发` : `发送到 ${m?.user.phone_hint ?? "手机"}` }}</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
</div>
<p v-if="err.other" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err.other }}</p>
</ConfirmDialog>
</template>
<style scoped>
.content > .card {
margin-bottom: var(--gap-card);
}
.kv .lnk {
margin-left: var(--sp-3);
}
.danger p {
margin: 0 0 var(--sp-3);
}
.field {
margin-top: var(--sp-3);
}
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.warn-text {
color: var(--warn-text);
}
.hint-warn {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
/**
* 额度与套餐(原型 quota.html,FR-M6-03、05,m6-plan §1.1–1.3):
* - 套餐和本期起止;开通 / 续费申请(对公转账,运营确认到账后开通,G8)和收款信息;到期后只读(M6-5);
* - 本期额度(剩余、已用、退回)、按功能分布、扣点规则(红线 9:扣点规则可见、可查);
* - 本期流水:每一笔都能对到项目和任务,余额逐条可核;项目删了写「已删除的项目」(G10);可以导出 CSV。
* 检查也从解析额度扣(data-model §4.3),所以只有一个「解析」账户。
*/
import { Download, Plus } from "@lucide/vue";
import { computed, ref } from "vue";
import ActivationDialog from "@/components/ActivationDialog.vue";
import { useCrumbs } from "@/lib/crumbs";
import { fmtDateTime, quotaUnit } from "@/lib/labels";
import { useActivationRequests, useQuota, useQuotaEntries, type QuotaEntry } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "额度与套餐" }]);
const quota = useQuota();
const q = computed(() => quota.data.value);
const plan = computed(() => q.value?.plan);
const acct = computed(() => q.value?.accounts.find((a) => a.unit === "parse"));
const requests = useActivationRequests();
const pending = computed(() => q.value?.pending_request ?? null);
const lastRejected = computed(() => {
const latest = requests.data.value?.items[0];
return latest && latest.status === "rejected" ? latest : null;
});
const day = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "");
const expired = computed(() => !!plan.value?.read_only);
const daysLeft = computed(() =>
plan.value?.ends_at ? Math.ceil((new Date(plan.value.ends_at).getTime() - Date.now()) / 86_400_000) : null,
);
/** 开通 / 续费按钮:免费版随时可以申请开通;专业版到期前 30 天起、已到期时可以续费;已续过一期或有待处理的申请时不显示 */
const canApply = computed(() => {
if (!plan.value || pending.value || q.value?.renewal) return false;
if (plan.value.code === "free" || expired.value) return true;
return daysLeft.value !== null && daysLeft.value <= 30;
});
const renewing = computed(() => plan.value?.code !== "free");
const dlg = ref(false);
/* ---------- 流水 ---------- */
const entriesQ = useQuotaEntries();
const rows = computed(() => entriesQ.data.value?.pages.flatMap((p) => p.items) ?? []);
const KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣(24 小时内复查)",
};
const REASON: Record<string, string> = { recheck_24h: "距上次扣额度的检查不到 24 小时" };
function refText(e: QuotaEntry): string {
const project = e.project ? (e.project.deleted ? "已删除的项目" : e.project.name ?? "") : "";
const task = e.ref?.label ?? (e.ref ? (e.ref.type === "check_run" ? "检查" : "解析") : "");
return [project, task].filter(Boolean).join(" · ");
}
function note(e: QuotaEntry): string {
if (e.reason) return REASON[e.reason] ?? e.reason;
if (e.kind === "release") return "失败、超时或取消,自动退回";
if (e.kind === "settle") return "任务完成";
return "";
}
const signed = (n: number) => (n > 0 ? `+${n}` : String(n));
/** 导出 CSV:把还没加载的页都取完,在浏览器里生成文件 */
const exporting = ref(false);
async function exportCsv() {
exporting.value = true;
try {
while (entriesQ.hasNextPage.value) await entriesQ.fetchNextPage();
const head = ["时间", "类型", "额度", "数量", "余额", "关联项目 · 任务", "说明"];
const lines = rows.value.map((e) =>
[fmtDateTime(e.created_at), KIND[e.kind] ?? e.kind, quotaUnit(e.unit), signed(e.amount), e.balance_after, refText(e), note(e)]
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
.join(","),
);
const blob = new Blob(["" + [head.join(","), ...lines].join("\r\n")], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `额度流水-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(a.href);
} catch {
toast("导出失败,请稍后再试", { ok: false });
} finally {
exporting.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>额度与套餐</h1>
<p v-if="plan">
<template v-if="plan.ends_at">{{ plan.name }} · 本期 <span class="fig">{{ day(plan.starts_at) }}</span> 至 <span class="fig">{{ day(plan.ends_at) }}</span>。额度绑定企业主体,不可转让;本期没用完的不结转。</template>
<template v-else>{{ plan.name }} · <span class="fig">{{ day(plan.starts_at) }}</span> 注册起长期有效,额度不再补发。额度绑定企业主体,不可转让。</template>
</p>
</div>
<div class="actions">
<button v-if="canApply" type="button" class="btn" :class="{ primary: expired || plan?.code === 'free' }" @click="dlg = true">
<Plus class="ic" :stroke-width="1.5" />{{ renewing ? "提交续费申请" : "开通专业版" }}
</button>
</div>
</div>
<div v-if="expired" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已于 <span class="fig">{{ day(plan?.ends_at) }}</span> 到期,数据只读:可以查看、下载已导出的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。</p>
</div>
<div v-else-if="daysLeft !== null && daysLeft <= 30 && !q?.renewal" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版还有 <span class="fig">{{ Math.max(daysLeft, 0) }}</span> 天到期,到期后数据只读、额度清零。</p>
</div>
<div v-if="q?.renewal" class="alert" role="note">
<span class="ico i" aria-hidden="true">i</span>
<p>已续费:新一期 <span class="fig">{{ day(q.renewal.starts_at) }}</span> 至 <span class="fig">{{ day(q.renewal.ends_at) }}</span>,到时自动生效并发放额度。</p>
</div>
<section v-if="pending" class="card pend" aria-labelledby="h-pend">
<div class="card-h">
<h2 id="h-pend">{{ renewing ? "续费" : "开通" }}申请 <span class="fig">{{ pending.request_no }}</span></h2>
<span class="tag">{{ pending.status === "paid" ? "已到账,等待开通" : "等待对公转账" }}</span>
</div>
<dl class="kv">
<dt>套餐</dt><dd>专业版 · 1 年</dd>
<dt>收款单位</dt><dd>{{ pending.payment.payee_name }}</dd>
<dt>开户行</dt><dd>{{ pending.payment.payee_bank }}</dd>
<dt>账号</dt><dd class="fig">{{ pending.payment.payee_account }}</dd>
<dt>转账备注</dt><dd>请写申请单号 <b class="fig">{{ pending.request_no }}</b></dd>
</dl>
<p class="cap muted foot-note">金额由运营联系你确认。运营确认到账后开通,并开发票;开通后新一期额度会记进下面的流水。</p>
</section>
<div v-else-if="lastRejected" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>申请 <span class="fig">{{ lastRejected.request_no }}</span> 被退回<template v-if="lastRejected.ops_note">:{{ lastRejected.ops_note }}</template>。可以修改后重新提交。</p>
</div>
<div class="cards">
<section class="card" aria-labelledby="h-acct">
<div class="card-h"><h2 id="h-acct">解析额度</h2><span v-if="acct" class="cap muted r">已用 <span class="fig">{{ Math.round((acct.used_ratio ?? 0) * 100) }}</span>%</span></div>
<template v-if="acct">
<small class="muted">剩余</small>
<div><span class="big" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }">{{ acct.balance }}</span><span class="unit">/ {{ acct.period_granted }} 次</span></div>
<div class="bar" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.min(100, Math.round((acct.used_ratio ?? 0) * 100))}%` }"></i>
</div>
<p class="cap muted">
已用 <span class="fig">{{ acct.period_used }}</span> 次 · 退回 <span class="fig">{{ acct.period_released ?? 0 }}</span> 次<template v-if="acct.held"> · 正在预扣 <span class="fig">{{ acct.held }}</span> 次</template>
</p>
</template>
<p v-else class="cap muted">读取中…</p>
</section>
<section class="card" aria-labelledby="h-usage">
<div class="card-h"><h2 id="h-usage">按功能分布</h2></div>
<ul v-if="q" class="usage">
<li><span>读标 · 招标文件</span><span><span class="fig">{{ q.usage.tender }}</span> <small>次解析</small></span></li>
<li><span>读标 · 补遗和澄清</span><span><span class="fig">{{ q.usage.addendum }}</span> <small>次解析</small></span></li>
<li><span>查标</span><span><span class="fig">{{ q.usage.check }}</span> <small>次解析</small></span></li>
<li v-if="q.usage.deleted"><span>已删除的项目</span><span><span class="fig">{{ q.usage.deleted }}</span> <small>次解析</small></span></li>
<li><span>24 小时内复查</span><span><span class="fig">{{ q.usage.waived }}</span> <small>次 · 免扣</small></span></li>
</ul>
</section>
</div>
<section class="card flush" aria-labelledby="h-rules">
<div class="card-h pad"><h2 id="h-rules">扣点规则</h2></div>
<table class="data">
<thead><tr><th>动作</th><th>扣点</th><th>不扣点</th></tr></thead>
<tbody>
<tr>
<td><b>解读一份招标文件、补遗或澄清</b></td>
<td>1 次解析:发起时预扣,成功后结算</td>
<td>解析失败、超时、取消:自动退回</td>
</tr>
<tr>
<td><b>对一个项目做一次检查</b></td>
<td>专业版 1 次解析(投标文件的解析含在内);免费版:这个项目内不限次数</td>
<td>检查中断、系统异常:自动退回<br />同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-ledger">
<div class="card-h pad">
<h2 id="h-ledger">流水</h2>
<button type="button" class="btn sm r" :disabled="exporting || !rows.length" @click="exportCsv"><Download class="ic" :stroke-width="1.5" />导出 CSV</button>
</div>
<table class="data ledger">
<thead>
<tr><th>时间</th><th>类型</th><th class="r">数量</th><th class="r">余额</th><th>关联项目 · 任务</th><th>说明</th></tr>
</thead>
<tbody>
<tr v-for="e in rows" :key="e.id">
<td class="fig nowrap">{{ fmtDateTime(e.created_at) }}</td>
<td>{{ KIND[e.kind] ?? e.kind }}</td>
<td class="num r">{{ signed(e.amount) }}</td>
<td class="num r">{{ e.balance_after }}</td>
<td :class="{ muted: e.project?.deleted }">{{ refText(e) || "—" }}</td>
<td class="muted">{{ note(e) }}</td>
</tr>
<tr v-if="!rows.length && !entriesQ.isPending.value"><td colspan="6" class="muted">还没有流水</td></tr>
</tbody>
</table>
<div v-if="entriesQ.hasNextPage.value" class="more">
<button type="button" class="btn sm text" :disabled="entriesQ.isFetchingNextPage.value" @click="entriesQ.fetchNextPage()">加载更多</button>
</div>
</section>
</div>
<ActivationDialog v-model:open="dlg" :renew="renewing" />
</template>
<style scoped>
.content > .alert,
.content > .card {
margin-bottom: var(--gap-card);
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--gap-card);
margin-bottom: var(--gap-card);
}
.cards .card {
margin: 0;
}
.card-h .r {
margin-left: auto;
}
.card-h.pad {
padding: var(--sp-4) var(--card-x) 0;
}
.big {
font: var(--fw-bold) var(--fs-figure) / var(--lh-figure) var(--font-figures);
font-variant-numeric: tabular-nums;
}
.big.warn {
color: var(--warn-text);
}
.unit {
margin-left: 4px;
color: var(--text-secondary);
font-family: var(--font-figures);
}
.bar {
margin: var(--sp-3) 0 var(--sp-2);
}
.usage {
margin: 0;
padding: 0;
list-style: none;
}
.usage li {
display: flex;
justify-content: space-between;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
.usage li:first-child {
border-top: 0;
}
.usage small {
color: var(--text-secondary);
}
.pend .kv {
margin: 0;
}
.foot-note {
margin: var(--sp-3) 0 0;
}
.ledger .r {
text-align: right;
}
.nowrap {
white-space: nowrap;
}
.more {
padding: var(--sp-2) var(--card-x) var(--sp-3);
}
</style>

View File

@@ -23,6 +23,8 @@ const projects = useProjects();
const quota = useQuota(); const quota = useQuota();
const parse = useQuotaAccount("parse"); const parse = useQuotaAccount("parse");
/** 套餐到期后只读(决策 M6-5):不能新建项目,顶上有续费提醒 */
const readOnly = computed(() => !!me.data.value?.plan.read_only);
const today = new Date(); const today = new Date();
const hello = computed(() => { const hello = computed(() => {
const name = me.data.value?.user.display_name ?? ""; const name = me.data.value?.user.display_name ?? "";
@@ -87,7 +89,7 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div> </div>
<div class="actions"> <div class="actions">
<span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span> <span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink> <RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div> </div>
</div> </div>
@@ -131,7 +133,7 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
<div v-else class="empty"> <div v-else class="empty">
<b>还没有项目</b> <b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p> <p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink> <RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div> </div>
</section> </section>

View File

@@ -25,6 +25,8 @@ export const router = createRouter({
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") }, { path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") }, { path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
{ path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.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", path: "projects/:projectId/parse/:jobId",
name: "parse", name: "parse",

View File

@@ -48,6 +48,13 @@
.quota-chip .fig{color:var(--text-primary);font-size:var(--fs-figure-sm)} .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 .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)} .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)} .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{width:100%;padding:20px var(--pad-page) var(--sp-12)}
.content.narrow{max-width:800px} .content.narrow{max-width:800px}

View File

@@ -12,6 +12,7 @@ services:
POSTGRES_PASSWORD: ${BD_PG_OWNER_PASSWORD:-dev-owner} POSTGRES_PASSWORD: ${BD_PG_OWNER_PASSWORD:-dev-owner}
POSTGRES_DB: bid_doctor POSTGRES_DB: bid_doctor
BD_APP_PASSWORD: ${BD_PG_APP_PASSWORD:-dev-app} 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"] ports: ["127.0.0.1:${BD_PG_PORT:-54329}:5432"]
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
@@ -68,6 +69,21 @@ services:
migrate: { condition: service_completed_successfully } migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy } 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: worker:
profiles: [app] profiles: [app]
image: bid-doctor-server image: bid-doctor-server

View File

@@ -1,9 +1,11 @@
#!/bin/sh #!/bin/sh
# 数据库角色(data-model §2.4):bd_owner 建表和迁移;bd_app 给 api / worker,受行级安全策略约束; # 数据库角色(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 set -eu
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL
CREATE ROLE bd_app LOGIN PASSWORD '${BD_APP_PASSWORD}'; CREATE ROLE bd_app LOGIN PASSWORD '${BD_APP_PASSWORD}';
CREATE ROLE bd_auth NOLOGIN BYPASSRLS; CREATE ROLE bd_auth NOLOGIN BYPASSRLS;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO bd_app; CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD '${BD_OPS_PASSWORD}';
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO bd_app, bd_ops;
SQL SQL

View File

@@ -101,6 +101,7 @@
- 只有最近一次检查的问题可以处理,历史批次返回 409 `run_superseded`; - 只有最近一次检查的问题可以处理,历史批次返回 409 `run_superseded`;
- 批量处理只限「提示」级,混进了别的级别就整批拒绝,返回不符合的问题编号。 - 批量处理只限「提示」级,混进了别的级别就整批拒绝,返回不符合的问题编号。
- **已递交、已归档的项目只读**:所有修改接口都返回 409 `project_read_only`。撤销递交或撤销归档之后才能改(FR-M6-02)。 - **已递交、已归档的项目只读**:所有修改接口都返回 409 `project_read_only`。撤销递交或撤销归档之后才能改(FR-M6-02)。
- **套餐到期后整个账号只读**(决策 M6-5):写操作返回 409 `plan_expired`;退出、续费申请、删除项目、注销账号、通知已读、反馈照常放行(在鉴权依赖里统一判断)。
### 4.6 不在接口里出现的东西 ### 4.6 不在接口里出现的东西
@@ -136,7 +137,7 @@
| 403 | `csrf_failed` `forbidden` | CSRF 头不对;没有这个权限点 | | 403 | `csrf_failed` `forbidden` | CSRF 头不对;没有这个权限点 |
| 404 | `not_found` `phone_not_registered` | | | 404 | `not_found` `phone_not_registered` | |
| 409 | `concurrent_session` | 已有其他设备在线(API3) | | 409 | `concurrent_session` | 已有其他设备在线(API3) |
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` | 额度、计费、套餐限制 | | 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` `plan_expired` `activation_pending` | 额度、计费、套餐限制和到期 |
| 409 | `job_already_running` `job_finished` | 任务状态冲突 | | 409 | `job_already_running` `job_finished` | 任务状态冲突 |
| 409 | `project_read_only` `invalid_transition` `run_superseded` `item_not_pending` `document_in_use` 等 | 业务状态不允许这个操作 | | 409 | `project_read_only` `invalid_transition` `run_superseded` `item_not_pending` `document_in_use` 等 | 业务状态不允许这个操作 |
| 412 | `precondition_failed` | If-Match 版本不一致 | | 412 | `precondition_failed` | If-Match 版本不一致 |
@@ -186,14 +187,14 @@
| 模块 | 已实现 | 还没实现 | | 模块 | 已实现 | 还没实现 |
|---|---|---| |---|---|---|
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 | | 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me`、注销账号(`POST /me/sms-codes` + `POST /tenant/close`,m6-plan) | 修改显示名、在线会话列表和下线 |
| 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 | | 额度与套餐 | `GET /plans`、`GET /quota`(含按功能分布、排队的续费、待处理的申请)、额度流水、开通申请(m6-plan) | — |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包 | 删除 | | 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包、删除(m6-plan) | — |
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 | | 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本、「已被补遗 N 更新」的确认 | — | | 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本、「已被补遗 N 更新」的确认 | — |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) | | 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — | | 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 | | 通知与事件 | 事件流(含检查进度)、通知列表(标题、链接、项目名称、分页)、标为已读、反馈 | — |
读标修改类接口的实现约定(2026-11-03): 读标修改类接口的实现约定(2026-11-03):

View File

@@ -0,0 +1,114 @@
# M6 剩下的 P0:额度流水、套餐开通、数据删除、运营后台(实现计划 v0.1)
依据:FR-M6-03 / 05 / 06 / 07 / 09(`docs/analysis/03-functional-requirements.md`),J4、J5(`02-user-journeys.md`),G6、G8、G10,决策 Q9、Q13、P1、DM4;表结构已在基线迁移里(`data-model.md` §4、§6),用户端接口已在 `openapi.yaml` 里定义;运营后台的接口和选型「做到时再定」(架构 §3.11、§4.3)。
## 0. 现状
| 方面 | 已有 | 还没有 |
|---|---|---|
| 额度 | 注册发放、预扣 / 结算 / 退回 / 免扣、`GET /quota`、顶栏额度(≥ 80% 赭色) | 流水接口和额度页、80% / 100% 提醒、人工调整、过期 |
| 套餐 | 免费版注册即开通、`GET /plans`、专业版套餐(解析 500,D2 待定) | 开通 / 续费申请、运营开通、到期处理 |
| 删除 | 资质删除(QL6) | 删除项目、注销账号 |
| 通知 | 解析、检查、报告完成写通知;事件流 | 前端通知入口、标为已读 |
| 运营后台 | 表:`ops_admin`、`ops_session`;角色 `bd_ops`(文档里有,部署脚本里还没建);反馈写入 | 进程、登录、接口、前端,全部没有 |
另外发现一处冲突:原型 `project.html` 的删除写「30 天内可以在回收站恢复」,而 G10、DM4(已确认)是删除时销毁项目数据密钥、彻底删除。按决策优先,见 M6-1。
## 1. 用户端
### 1.1 额度页(FR-M6-03,原型 `quota.html`)
- 侧栏账户区(头像、套餐标签)点开进「额度与套餐」页;顶栏额度改成链接,也进这个页。
- 页面:本期额度卡(剩余 / 已用 / 退回,期间起止)→ 扣点规则表(原型原样)→ 按功能分布(读标招标文件、补遗和澄清、查标、免扣次数)→ 本期流水。
- 流水:`GET /quota/entries`,按单位筛选、按时间倒序分页;每行写时间、类型(发放 / 预扣 / 结算 / 退回 / 免扣 / 过期 / 调整)、数量(+ / −,墨色,Q1)、余额、关联项目 · 任务(「招标文件」「第 2 次检查」;项目删了写「已删除的项目」和任务编号,G10)、说明(调整原因、失败原因、「距上次扣额度的检查不到 24 小时」)。导出 CSV 在前端生成。
- 提醒:预扣后余额跨过 80%、100%(按本期发放算)时写站内通知,`quota_alert` 保证每期每档只提醒一次。
### 1.2 开通与续费(FR-M6-05,G8)
- 免费版:额度页和顶栏「额度用完」提示里有「开通专业版」;专业版:到期前 30 天起显示「续费」。
- 申请对话框(原型):企业名称(注册时的,只读)、发票类型(普票 / 专票)、纳税人识别号、联系人、手机号;专票再加地址电话、开户行账号。`POST /activation-requests`,不扣额度、不用先付款。
- 提交后页面顶部显示申请单号、状态(等待对公转账 / 已到账 / 已开通 / 已退回)、收款信息(收款单位、开户行、账号,从配置读 `BD_PAYEE_*`)和「转账备注写申请单号」。
- 运营确认到账并开通:建专业版订阅、发放额度(流水「发放」,说明写申请单号),发站内通知;之后填发票号。
- 续费:当前专业版还没到期时,新一期从旧一期结束时开始;旧一期剩下的额度到期清零(「本期没用完的不结转」,原型)。
### 1.3 到期(FR-M6-05)
- worker 每天跑一次定时任务(Procrastinate 周期任务):到期的订阅改「已过期」、记「过期」流水把余额清零;排队的下一期到点发放。
- 提前 30 天、7 天、到期当天各发一次站内通知。
- 过期后:数据保留,只读(M6-5)。
### 1.4 删除项目(J4、G10、data-model §6.1)
- 入口:项目头「更多」菜单(和归档放一起),已递交、已归档的也能删。
- 确认框:写明删掉什么、保留什么(额度流水、不含业务内容的审计),输入项目名称确认(原型)。
- `DELETE /projects/{id}`:同一事务里写审计、抹掉这个项目审计的内容、删项目行(级联)、销毁数据密钥;提交后后台任务删对象存储前缀。正在解析 / 检查的项目先取消任务再删(预扣退回)。
### 1.5 账号页与注销(J4、data-model §6.2)
- 「额度与套餐」页旁边一个「账号」页:企业名称、手机号(脱敏)、显示名、套餐;最下面「注销账号」。
- 注销:确认框写明后果(全部项目、资质库删除;开通申请和发票记录按财务要求保留;剩余额度作废、不退款),输入企业名称 + 短信验证码。`POST /tenant/close`:立即吊销会话、企业标「已注销」,项目按 §1.4 由后台任务逐个删。
### 1.6 通知入口
- 顶栏加铃铛(原型有),有未读时带圆点;点开是浮层列表(最近 30 条:解析 / 检查 / 报告完成、额度提醒、开通结果、到期提醒),点一条跳到相关页面并标为已读,「全部标为已读」。`POST /notifications/read`。
## 2. 运营后台(FR-M6-09)
### 2.1 形态
- **后端**:同一个 Python 包的第三种进程 `bid-doctor ops`(和 api、worker 一样的镜像),单独端口、路径前缀 `/ops/v1`,用 `bd_ops` 数据库角色(绕过行级安全策略);用户端 api 进程里不挂任何运营接口。部署时运营端口只对内网 / 白名单开放。
- **登录**:用户名 + 密码 + TOTP 二次验证;IP 白名单(`BD_OPS_ALLOWED_IPS`,空着时拒绝启动于生产环境);会话 8 小时;账号用命令行建(`bid-doctor ops-admin create`),没有自助注册。
- **前端**:`apps/admin`,Vue 3 + Vite,复用 `tokens.css` 和组件样式,不引入带样式的框架(M6-3);单独构建、单独部署,私有化不交付。
- **审计**:每个运营操作(包括查询企业详情)都写审计事件,操作人记运营账号。
### 2.2 页面和接口(最小版)
| 页面 | 做什么 | 接口(`/ops/v1`) |
|---|---|---|
| 企业 | 按企业名称、手机号(用 HMAC 查)、申请单号搜;详情:成员(手机号脱敏)、套餐和到期日、各单位余额、项目数、最近任务;**不显示项目名称和任何业务内容** | `GET /tenants`、`GET /tenants/{id}` |
| 开通申请 | 待处理在前;标记「已到账」→「开通」(选套餐和周期,默认专业版 1 年),或「退回」写原因;开通后填发票号 | `GET /activation-requests`、`POST .../{id}/activate`、`/reject`、`/invoice` |
| 额度调整 | 在企业详情里:单位、±数量、原因(必填) | `POST /tenants/{id}/quota-adjustments` |
| 反馈 | 误报 / 漏报列表:类型、用户写的理由、问题标题和级别、规则编号、提示词版本、出处页码,**不显示招标 / 投标文件原文**(M6-7) | `GET /feedback` |
| 失败任务 | 解析、检查、报告失败的:错误类别、错误码、阶段、耗时、模型调用次数,不含文档内容 | `GET /failed-jobs` |
| 审计 | 按企业 / 对象 / 时间查审计事件(项目删除后只剩类型和时间) | `GET /audit-events` |
## 3. 步骤
1. 用户端后端:流水、提醒、开通申请、到期任务、删除项目、注销、通知已读,各配集成测试(额度对账:任何失败任务都没有净扣点;删除后对象不可解密)。
2. 用户端前端:额度与套餐页、账号页、通知入口、删除项目。
3. 运营后台后端:`ops` 进程、`bd_ops` 角色(部署脚本)、登录和 TOTP、上表接口、审计。
4. 运营后台前端:`apps/admin` 六个页面。
5. 浏览器走查:注册 → 用完免费额度 → 申请开通 → 运营开通 → 流水核对 → 到期(改时钟)→ 删除项目 → 注销。
## 4. 待确认(建议见每条)
| # | 问题 | 建议 |
|---|---|---|
| M6-1 | 删除项目要不要「30 天回收站」(原型这么写) | **不要,立即彻底删除**:按已确认的 G10、DM4 销毁数据密钥;回收站要软删除,和「彻底删除」冲突(同 QL6)。确认框要求输入项目名称,写明不能恢复。原型文案改掉 |
| M6-2 | 运营接口放在哪 | **单独的第三种进程 `bid-doctor ops`**(同一镜像、单独端口、`bd_ops` 角色);用户端 api 进程里没有运营接口,绕过行级安全策略的代码不会被公网请求触到 |
| M6-3 | 运营后台前端选型 | **Vue 3 + 复用 tokens.css / 组件样式,不引入带样式框架**:页面只有六个、以表格和表单为主;和用户端同一套组件,维护成本低。另一种做法是引入 Element Plus 之类加快开发,但多一套视觉和依赖 |
| M6-4 | 运营登录 | **用户名 + 密码 + TOTP + IP 白名单**,账号命令行创建(架构 §3.11 已写独立登录、白名单、二次验证) |
| M6-5 | 套餐到期后怎么办 | **不自动降为免费版**;项目只读(能看、能下载已导出的报告、能删除;不能新建项目、解析、检查、改清单);额度清零;提前 30 天、7 天、当天提醒。续费到账后恢复 |
| M6-6 | 开通申请页写不写价格、收款信息从哪来 | **页面不写价格**(D2 额度数字和定价都还没定),写「运营会联系你确认金额」;收款单位 / 开户行 / 账号从配置读,没配时显示「(待定)」,生产环境没配拒绝启动 |
| M6-7 | 运营看反馈时能不能看原文 | **不看原文**:只看标题、理由、规则编号、提示词版本、页码(红线 10:数据敏感、按租户隔离)。要复盘原文时请用户导出报告发过来,或以后做「用户授权查看」 |
| M6-8 | 注销账号的确认方式 | **输入企业名称 + 短信验证码**;有没到期的专业版时写明剩余额度作废、不退款 |
## 5. 落地记录(2026-11-05)
M6-1–M6-8 全部按建议确认后实现。和上文不一样、或计划里没写到的:
- **迁移 0010**:订阅加「排队中」(scheduled),续费到账时当前一期还没到期就排队,到点由定时任务生效发放;开通申请加申请单号 `request_no`(KT- 加 6 位);通知加「已到期」「申请被退回」;`billing_due(now)` 是 bd_auth 所有的 SECURITY DEFINER 函数,定时任务(worker 每小时 `billing_tick`)用它跨企业找到期、该生效、该提醒的订阅,只拿编号,处理时回到各企业自己的租户事务。到期时还有没了结的预扣就等下一次再清零,避免退回的 1 次漏在余额里。
- **迁移 0011**:`bd_ops` 的权限(跨企业只读、运营账号和会话表可写、审计可写),`bd_app` 收回运营账号表的权限。角色本身由部署脚本建(`init-roles.sh`,CI 同一个脚本)。
- **只读的落点**:到期后写操作在鉴权依赖里统一拦(`identity/deps.py` 的 `READ_ONLY_ALLOWED`),退出、续费申请、删除项目、注销、通知已读、反馈、管理登录设备照常放行;不用逐个接口去改。
- **额度单位**:只发放现在用得上的「解析」;专业版套餐里的 `generate`(M4 标书生成)等功能上线再发,免得顶栏出现用不了的额度。
- **删除项目**:正在解析、检查、生成报告时返回 409 `job_already_running`,先取消(预扣退回)再删;`data_key` 销毁以后不再建新密钥,还在跑的任务写不回文件。审计的 `before` / `after` 改为 Python 的 None 存成 SQL NULL(以前是 JSON 的 null)。
- **注销账号**:验证码改为登录后 `POST /me/sms-codes` 发到自己的号码(前端不知道完整号码);同步部分完成后手机号就能重新注册,删项目、资质库、抹审计内容、销毁密钥、清对象存储由后台任务 `close_tenant` 做,可以重复执行。
- **uuid7 单调**:同一进程内严格递增(RFC 9562 方法 2),额度流水按编号排序算逐条余额。
- **顺手修的**:取消解析、取消检查的接口在刷新以前转 API 会触发懒加载报错(MissingGreenlet),取消按钮会 500。
- **运营后台**:接口说明见 `ops-api.md`;前端 `apps/admin`(`pnpm dev:admin`,:5174)六个页面:登录、开通申请、企业(含调额度)、误报 / 漏报、失败任务、审计。
测试:后端 156 个(新增 `test_billing_flow.py` 4 个、`test_ops_console.py` 3 个,覆盖流水对账、提醒只发一次、开通 / 续费 / 到期只读、删除后不可解密、注销后可重新注册、运营登录和 IP 白名单、`bd_app` 读不到运营表、`bd_ops` 改不了业务表)。浏览器走查:注册 → 申请开通 → 运营标记到账、开通、登记发票 → 流水核对 → 调额度 → 到期只读 → 删除项目 → 注销账号。
没做的:运营后台没有「撤回申请」「延长订阅」这类操作(需要时用退回 + 重新申请、调额度代替);收款信息和发票是线下流程,系统只登记发票号。
**运营后台第二版(2026-11-05,决策 M6-9、M6-10)**:菜单改到左侧(墨色渐变底,标准横版 logo 反白,非生产环境挂赭色环境标签),分组可以展开收起、每项带图标和待办数(`GET /summary`),底部是当前运营人员(姓名、角色)和退出;登录页加 logo。运营人员的角色只留结构:迁移 0012 加 `ops_admin.role`,权限点和「角色 → 权限点」对应表、每个接口的检查点、按权限点过滤的菜单都已就位,现在只有管理员。见 `ops-api.md`。

View File

@@ -203,12 +203,40 @@ paths:
'204': { description: 已吊销 } '204': { description: 已吊销 }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
/me/sms-codes:
post:
tags: [me]
operationId: sendMySmsCode
summary: 登录后发验证码到自己的手机号(注销账号的二次确认,前端不需要完整号码)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [purpose]
properties:
purpose: { type: string, enum: [close_tenant] }
responses:
'202':
description: 已发送
content:
application/json:
schema:
type: object
required: [resend_after_s]
properties:
resend_after_s: { type: integer }
'429': { $ref: '#/components/responses/RateLimited' }
/tenant/close: /tenant/close:
post: post:
tags: [me] tags: [me]
operationId: closeTenant operationId: closeTenant
summary: 注销账号(删除企业下全部业务数据,不可恢复) summary: 注销账号(删除企业下全部业务数据,不可恢复)
description: data-model §6.2。需要短信验证码和企业名称两次确认。成功后当前会话失效。 description: |
data-model §6.2,决策 M6-8。需要短信验证码(`POST /me/sms-codes`)和企业名称两次确认;
企业名称不一致返回 422 `tenant_name_mismatch`。成功后所有会话失效,手机号可以重新注册;项目、资质库等由后台删除。
requestBody: requestBody:
required: true required: true
content: content:
@@ -2168,7 +2196,7 @@ components:
description: 给用户看的检查计费说明,如「检查从解析额度扣 1 次;同一项目 24 小时内复查不扣」 description: 给用户看的检查计费说明,如「检查从解析额度扣 1 次;同一项目 24 小时内复查不扣」
QuotaOverview: QuotaOverview:
type: object type: object
required: [plan, accounts] required: [plan, accounts, usage]
properties: properties:
plan: { $ref: '#/components/schemas/PlanSummary' } plan: { $ref: '#/components/schemas/PlanSummary' }
accounts: accounts:
@@ -2180,26 +2208,44 @@ components:
unit: { $ref: '#/components/schemas/QuotaUnit' } unit: { $ref: '#/components/schemas/QuotaUnit' }
balance: { type: integer, description: 剩余,含正在预扣的 } balance: { type: integer, description: 剩余,含正在预扣的 }
held: { type: integer, description: 正在预扣、还没结算的 } held: { type: integer, description: 正在预扣、还没结算的 }
period_granted: { type: integer } period_granted: { type: integer, description: 本期发放(按当前这一期订阅) }
period_used: { type: integer } period_used: { type: integer, description: 本期净用量(预扣 − 退回) }
period_released: { type: integer, description: 本期退回的次数(失败、超时、取消) }
used_ratio: { type: number, description: '0~1;≥ 0.8 时界面变赭色(Q9)' } used_ratio: { type: number, description: '0~1;≥ 0.8 时界面变赭色(Q9)' }
by_feature: usage:
type: array
description: 按功能分布,如解析、检查
items:
type: object type: object
required: [feature, used] description: 本期按功能分布(没被退回的预扣);deleted 是项目已删除、看不出用途的;waived 是 24 小时内复查的免扣次数
required: [tender, addendum, check, deleted, waived]
properties: properties:
feature: { type: string, enum: [parse, check] } tender: { type: integer }
used: { type: integer } addendum: { type: integer, description: 补遗和澄清 }
check: { type: integer }
deleted: { type: integer }
waived: { type: integer }
renewal:
description: 已续费、排队等当前一期结束后生效的那一期
oneOf:
- type: 'null'
- type: object
required: [starts_at, ends_at]
properties:
starts_at: { $ref: '#/components/schemas/Timestamp' }
ends_at: { $ref: '#/components/schemas/Timestamp' }
pending_request:
description: 还在等转账或开通的申请
oneOf:
- type: 'null'
- $ref: '#/components/schemas/ActivationRequest'
QuotaEntry: QuotaEntry:
type: object type: object
required: [id, unit, kind, amount, created_at] required: [id, unit, kind, amount, balance_after, created_at]
properties: properties:
id: { $ref: '#/components/schemas/Id' } id: { $ref: '#/components/schemas/Id' }
unit: { $ref: '#/components/schemas/QuotaUnit' } unit: { $ref: '#/components/schemas/QuotaUnit' }
kind: { $ref: '#/components/schemas/QuotaEntryKind' } kind: { $ref: '#/components/schemas/QuotaEntryKind' }
amount: { type: integer, description: 对余额的影响;结算、免扣为 0 } amount: { type: integer, description: 对余额的影响;结算、免扣为 0 }
balance_after: { type: integer, description: 记完这一条以后的账户余额 }
hold_entry_id: { oneOf: [{ $ref: '#/components/schemas/Id' }, { type: 'null' }], description: 结算、退回对应的预扣 }
reason: { type: string, description: '调整原因;免扣时为规则说明,如「24 小时内复查」' } reason: { type: string, description: '调整原因;免扣时为规则说明,如「24 小时内复查」' }
project: project:
type: [object, 'null'] type: [object, 'null']
@@ -2222,7 +2268,8 @@ components:
invoice_info: { $ref: '#/components/schemas/InvoiceInfo' } invoice_info: { $ref: '#/components/schemas/InvoiceInfo' }
InvoiceInfo: InvoiceInfo:
type: object type: object
required: [title, tax_no] required: [title, tax_no, contact_name, contact_phone]
description: 专票(invoice_type = special)还要地址电话和开户行账号
properties: properties:
title: { type: string, description: 发票抬头 } title: { type: string, description: 发票抬头 }
tax_no: { type: string, description: 纳税人识别号 } tax_no: { type: string, description: 纳税人识别号 }
@@ -2233,14 +2280,27 @@ components:
contact_phone: { type: string } contact_phone: { type: string }
ActivationRequest: ActivationRequest:
type: object type: object
required: [id, plan_code, status, created_at] required: [id, request_no, plan_code, status, payment, created_at]
properties: properties:
id: { $ref: '#/components/schemas/Id' } id: { $ref: '#/components/schemas/Id' }
request_no: { type: string, examples: [KT-7Q3M2X], description: 申请单号,对公转账时写在备注里 }
plan_code: { $ref: '#/components/schemas/PlanCode' } plan_code: { $ref: '#/components/schemas/PlanCode' }
status: { type: string, enum: [submitted, paid, activated, rejected, cancelled] } status:
type: string
enum: [submitted, paid, activated, rejected, cancelled]
description: 等待转账 / 已到账 / 已开通 / 已退回 / 已撤回
invoice_info: { $ref: '#/components/schemas/InvoiceInfo' } invoice_info: { $ref: '#/components/schemas/InvoiceInfo' }
payment_instructions: { type: string, description: 对公转账账户和备注要求 } payment:
ops_note: { type: string } type: object
description: 收款信息(配置项;没配时为「(待定)」,决策 M6-6)。页面不写价格,运营联系确认金额
required: [payee_name, payee_bank, payee_account]
properties:
payee_name: { type: string }
payee_bank: { type: string }
payee_account: { type: string }
ops_note: { type: [string, 'null'], description: 退回原因 }
invoice_no: { type: [string, 'null'] }
handled_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] }
created_at: { $ref: '#/components/schemas/Timestamp' } created_at: { $ref: '#/components/schemas/Timestamp' }
# ---------- projects # ---------- projects
@@ -3180,13 +3240,14 @@ components:
kind: kind:
type: string type: string
enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, report_succeeded, report_failed, quota_80, quota_100, enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, report_succeeded, report_failed, quota_80, quota_100,
subscription_activated, subscription_expiring] subscription_activated, subscription_expiring, subscription_expired, activation_rejected]
title: { type: string } title: { type: string }
project_id: project_id:
oneOf: oneOf:
- $ref: '#/components/schemas/Id' - $ref: '#/components/schemas/Id'
- type: 'null' - type: 'null'
link: { type: [string, 'null'], description: '前端路由,如 /projects/<id>/results' } project_name: { type: [string, 'null'] }
link: { type: [string, 'null'], description: '前端路由,如 /projects/<id>/results、/quota' }
read_at: read_at:
oneOf: oneOf:
- $ref: '#/components/schemas/Timestamp' - $ref: '#/components/schemas/Timestamp'

View File

@@ -0,0 +1,65 @@
# 运营后台接口(/ops/v1)
运营后台的接口不在用户端契约 `openapi.yaml` 里:它们只挂在 `bid-doctor ops` 进程上(决策 M6-2),前端 `apps/admin` 是唯一的调用方,类型在 `apps/admin/src/api.ts` 里手写。实现见 `server/src/bid_doctor/ops/`(`console_api.py` 路由、`console.py` 查询、`auth.py` 登录)。
## 安全
| 方面 | 做法 |
|---|---|
| 进程 | 和 api、worker 同一镜像的第三种进程,单独端口(开发 8019);部署时只对内网开放 |
| 来源地址 | `BD_OPS_ALLOWED_IPS`(CIDR 列表)以外的请求一律 403 `ops_ip_denied`;生产环境不配或放开全部地址拒绝启动 |
| 登录 | 用户名 + 密码(scrypt)+ TOTP 6 位动态码(身份验证器 App);同一用户名 15 分钟最多试 5 次;账号只能用 `bid-doctor ops-admin create` 建 |
| 会话 | cookie `bd_ops_session`(HttpOnly、SameSite=Strict,8 小时)+ 写操作带 `X-CSRF-Token`(等于 cookie `bd_ops_csrf`) |
| 数据库 | 跨企业查询用 `bd_ops`(绕过行级安全策略,业务表只读,运营账号和会话表可写,审计可写);对某个企业的修改走 `bd_app` + 那个企业的租户事务。`bd_app` 读不到运营账号表 |
| 留痕 | 每个操作都写审计(`actor_kind = ops`,记运营账号),查看企业详情也记一条 `ops.tenant.view` |
| 内容 | 不显示项目名称、招标 / 投标原文、审计的修改前后(决策 M6-7、红线 10) |
## 角色和权限点(只留结构,决策 M6-10)
以后会有管理员、运营、销售、运维等不同权限的运营人员。现在只建结构:
- `ops_admin.role`(迁移 0012,不加 CHECK);
- 权限点 `OpsPermission`(`ops/auth.py`):`tenant.view`、`quota.adjust`、`activation.manage`、`invoice.manage`、`feedback.view`、`jobs.view`、`audit.view`;
- 「角色 → 权限点」对应表 `ROLE_PERMISSIONS`:现在只有 `admin`(全部权限)。表里有、对应表里没有的角色什么权限都没有;
- 每个接口开头都调 `auth.require(op, 权限点)`,没有权限返回 403 `forbidden`;`/me` 返回角色和权限点,前端菜单按权限点过滤(`apps/admin/src/nav.ts`),一组都看不到时整组不显示。
加角色时只改 `ROLE_PERMISSIONS`、`ROLE_LABEL` 和 `ops-admin create --role` 的说明;要不要有「运营账号管理」页面、谁能建账号,到时再定。
## 菜单(决策 M6-9)
左侧菜单由 `apps/admin/src/nav.ts` 一处配置:分组(客户、质量、安全)→ 菜单项(图标、路由、要的权限点、待办数)。分组可以展开收起,记在本机;跳到收起分组里的页面时自动展开。待办数来自 `GET /summary`(待处理的开通申请、近 7 天失败任务、近 7 天反馈),做完操作和每分钟刷新一次。
## 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/auth/login` | `{username, password, totp}`;失败 401 `ops_login_failed` |
| POST | `/auth/logout` | |
| GET | `/me` | 当前运营账号:角色、权限点、运行环境(非生产环境时菜单顶部提示) |
| GET | `/summary` | 菜单上的待办数;没有权限的那一项给 0 |
| GET | `/tenants?q=` | 企业名称(模糊)、手机号(精确,按 HMAC 查)、申请单号;不带条件列最近注册的 50 个 |
| GET | `/tenants/{id}` | 成员(手机号脱敏)、套餐历史、最近 30 条额度流水(不带项目名称)、任务按状态计数 |
| POST | `/tenants/{id}/quota-adjustments` | `{unit: parse, amount, reason}`;原因必填,不能扣成负数(G6) |
| GET | `/activation-requests?status=` | 待处理(等待转账、已到账)在前 |
| POST | `/activation-requests/{id}/paid` | 标记到账(只限等待转账) |
| POST | `/activation-requests/{id}/activate` | `{months = 12}`;免费版 / 已过期马上开始新一期,专业版没到期时新一期排队(续费) |
| POST | `/activation-requests/{id}/reject` | `{note}`,用户在额度页看得到原因 |
| POST | `/activation-requests/{id}/invoice` | `{invoice_no}`,开通以后才能登记 |
| GET | `/feedback?kind=` | 误报 / 漏报:标题、级别、理由、规则编号、规则集和提示词版本、页码 |
| GET | `/failed-jobs` | 解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数 |
| GET | `/audit-events?tenant_id=&action=` | 动作、对象、操作人、时间;`action` 按前缀筛选 |
## 本地跑起来
```
cd server && ../.venv/bin/bid-doctor ops # :8019
../.venv/bin/bid-doctor ops-admin create --username dev --display-name 开发运营 # 打印 otpauth:// 地址,用身份验证器 App 添加
pnpm dev:admin # :5174,/ops 代理到 :8019
```
已有的数据卷是在加 `bd_ops` 之前初始化的,要手工建一次角色(新数据卷由 `deploy/compose/postgres/init-roles.sh` 建):
```
docker exec -e PGPASSWORD=dev-owner bid-doctor-postgres-1 psql -U bd_owner -d bid_doctor \
-c "CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD 'dev-ops'" -c "GRANT CONNECT ON DATABASE bid_doctor TO bd_ops"
```

View File

@@ -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 处全部修 ## E3 · 2026-11-05 · 国动真实投标文件暴露的 6 处全部修
- **决策**:国动真实投标文件跑完一次(`check-eval.md` §14)后,暴露的 6 处全部修:响应错位的复合肯定写法、附加条件的否定句、扫描件判断、不需要这家投标人回答的条目、提纲式的「方案应明确……」、同一批片段的出处核对。规则集 `2026.11-r5`。 - **决策**:国动真实投标文件跑完一次(`check-eval.md` §14)后,暴露的 6 处全部修:响应错位的复合肯定写法、附加条件的否定句、扫描件判断、不需要这家投标人回答的条目、提纲式的「方案应明确……」、同一批片段的出处核对。规则集 `2026.11-r5`。

View File

@@ -210,7 +210,7 @@ function act(k) {
else if (k === "unarchive") { setStatus(before === "已归档" ? "已检查" : before); toast("已撤销归档", { ok: false }); } else if (k === "unarchive") { setStatus(before === "已归档" ? "已检查" : before); toast("已撤销归档", { ok: false }); }
else if (k === "delete") dialog({ else if (k === "delete") dialog({
title: `删除项目「${esc(D.project.name)}」?`, title: `删除项目「${esc(D.project.name)}」?`,
body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 30 天内可以在回收站恢复,之后彻底清除。</p> body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,不能恢复。需要留底的报告请先下载。</p>
<p class="cap muted">额度流水和一条不含业务内容的审计记录会保留。</p> <p class="cap muted">额度流水和一条不含业务内容的审计记录会保留。</p>
<div class="field" data-field><label for="dn">输入项目名称确认</label><input class="input" id="dn" placeholder="${esc(D.project.name)}" autocomplete="off"></div>`, <div class="field" data-field><label for="dn">输入项目名称确认</label><input class="input" id="dn" placeholder="${esc(D.project.name)}" autocomplete="off"></div>`,
ok: "删除项目", ok: "删除项目",
@@ -228,7 +228,7 @@ $("[data-state-acts]").addEventListener("click", (e) => {
if (b.matches("[data-more]")) { if (b.matches("[data-more]")) {
e.stopPropagation(); e.stopPropagation();
const items = status === "已归档" ? [{ label: "删除项目", act: "delete" }] 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)); App.floatMenu(b, items, (k) => act(k));
} }
}); });

View File

@@ -2,11 +2,14 @@
"name": "bid-doctor", "name": "bid-doctor",
"private": true, "private": true,
"packageManager": "pnpm@10.34.5", "packageManager": "pnpm@10.34.5",
"engines": { "node": ">=22" }, "engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"dev": "pnpm --filter @bid-doctor/web dev", "dev": "pnpm --filter @bid-doctor/web dev",
"build": "pnpm -r build", "build": "pnpm -r build",
"typecheck": "pnpm -r typecheck", "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"
} }
} }

View File

@@ -129,6 +129,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/tenant/close": {
parameters: { parameters: {
query?: never; query?: never;
@@ -140,7 +157,9 @@ export interface paths {
put?: never; 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"]; post: operations["closeTenant"];
delete?: never; delete?: never;
@@ -1572,17 +1591,31 @@ export interface components {
balance: number; balance: number;
/** @description 正在预扣、还没结算的 */ /** @description 正在预扣、还没结算的 */
held?: number; held?: number;
/** @description 本期发放(按当前这一期订阅) */
period_granted: number; period_granted: number;
/** @description 本期净用量(预扣 − 退回) */
period_used: number; period_used: number;
/** @description 本期退回的次数(失败、超时、取消) */
period_released?: number;
/** @description 0~1;≥ 0.8 时界面变赭色(Q9) */ /** @description 0~1;≥ 0.8 时界面变赭色(Q9) */
used_ratio?: number; 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: { QuotaEntry: {
id: components["schemas"]["Id"]; id: components["schemas"]["Id"];
@@ -1590,6 +1623,10 @@ export interface components {
kind: components["schemas"]["QuotaEntryKind"]; kind: components["schemas"]["QuotaEntryKind"];
/** @description 对余额的影响;结算、免扣为 0 */ /** @description 对余额的影响;结算、免扣为 0 */
amount: number; amount: number;
/** @description 记完这一条以后的账户余额 */
balance_after: number;
/** @description 结算、退回对应的预扣 */
hold_entry_id?: components["schemas"]["Id"] | null;
/** @description 调整原因;免扣时为规则说明,如「24 小时内复查」 */ /** @description 调整原因;免扣时为规则说明,如「24 小时内复查」 */
reason?: string; reason?: string;
project?: { project?: {
@@ -1612,6 +1649,7 @@ export interface components {
plan_code: "pro"; plan_code: "pro";
invoice_info: components["schemas"]["InvoiceInfo"]; invoice_info: components["schemas"]["InvoiceInfo"];
}; };
/** @description 专票(invoice_type = special)还要地址电话和开户行账号 */
InvoiceInfo: { InvoiceInfo: {
/** @description 发票抬头 */ /** @description 发票抬头 */
title: string; title: string;
@@ -1621,18 +1659,30 @@ export interface components {
bank_account?: string; bank_account?: string;
/** @enum {string} */ /** @enum {string} */
invoice_type?: "general" | "special"; invoice_type?: "general" | "special";
contact_name?: string; contact_name: string;
contact_phone?: string; contact_phone: string;
}; };
ActivationRequest: { ActivationRequest: {
id: components["schemas"]["Id"]; id: components["schemas"]["Id"];
/** @description 申请单号,对公转账时写在备注里 */
request_no: string;
plan_code: components["schemas"]["PlanCode"]; plan_code: components["schemas"]["PlanCode"];
/** @enum {string} */ /**
* @description 等待转账 / 已到账 / 已开通 / 已退回 / 已撤回
* @enum {string}
*/
status: "submitted" | "paid" | "activated" | "rejected" | "cancelled"; status: "submitted" | "paid" | "activated" | "rejected" | "cancelled";
invoice_info?: components["schemas"]["InvoiceInfo"]; invoice_info?: components["schemas"]["InvoiceInfo"];
/** @description 对公转账账户和备注要求 */ /** @description 收款信息(配置项;没配时为「(待定)」,决策 M6-6)。页面不写价格,运营联系确认金额 */
payment_instructions?: string; payment: {
ops_note?: string; 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"]; created_at: components["schemas"]["Timestamp"];
}; };
/** @description 就绪状态只在 status = checked 时有意义(G7);未就绪也不阻止导出和递交(红线 4) */ /** @description 就绪状态只在 status = checked 时有意义(G7);未就绪也不阻止导出和递交(红线 4) */
@@ -2309,10 +2359,11 @@ export interface components {
Notification: { Notification: {
id: components["schemas"]["Id"]; id: components["schemas"]["Id"];
/** @enum {string} */ /** @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; title: string;
project_id?: components["schemas"]["Id"] | null; project_id?: components["schemas"]["Id"] | null;
/** @description 前端路由,如 /projects/<id>/results */ project_name?: string | null;
/** @description 前端路由,如 /projects/<id>/results、/quota */
link?: string | null; link?: string | null;
read_at?: components["schemas"]["Timestamp"] | null; read_at?: components["schemas"]["Timestamp"] | null;
created_at: components["schemas"]["Timestamp"]; created_at: components["schemas"]["Timestamp"];
@@ -2650,6 +2701,36 @@ export interface operations {
404: components["responses"]["NotFound"]; 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: { closeTenant: {
parameters: { parameters: {
query?: never; query?: never;

31
pnpm-lock.yaml generated
View File

@@ -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: apps/web:
dependencies: dependencies:
'@bid-doctor/api-client': '@bid-doctor/api-client':

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
from __future__ import annotations 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 from bid_doctor.core.db import Base
@@ -12,13 +15,15 @@ class AuditEvent(Base):
tenant_id: Mapped[UuidCol] tenant_id: Mapped[UuidCol]
actor_kind: Mapped[str] actor_kind: Mapped[str]
actor_user_id: Mapped[UuidCol | None] actor_user_id: Mapped[UuidCol | None]
actor_ops_id: Mapped[UuidCol | None] = mapped_column(default=None)
actor_name: Mapped[str | None] actor_name: Mapped[str | None]
action: Mapped[str] action: Mapped[str]
object_type: Mapped[str] object_type: Mapped[str]
object_id: Mapped[UuidCol | None] object_id: Mapped[UuidCol | None]
project_id: Mapped[UuidCol | None] project_id: Mapped[UuidCol | None]
before: Mapped[Json | None] # None 存成 SQL NULL(不是 JSON 的 null):抹掉内容的触发器和「还有没有内容」的判断都按 IS NULL
after: Mapped[Json | None] 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] content_digest: Mapped[Bytes | None]
request_id: Mapped[str | None] request_id: Mapped[str | None]
created_at: Mapped[DbTs] created_at: Mapped[DbTs]

View File

@@ -26,6 +26,7 @@ async def record(
before: dict[str, Any] | None = None, before: dict[str, Any] | None = None,
after: dict[str, Any] | None = None, after: dict[str, Any] | None = None,
actor_kind: str = "user", actor_kind: str = "user",
actor_ops_id: UUID | None = None,
) -> None: ) -> None:
digest = ( digest = (
sha256(json.dumps([before, after], ensure_ascii=False, sort_keys=True, default=str)) 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, tenant_id=ctx.tenant_id,
actor_kind=actor_kind, actor_kind=actor_kind,
actor_user_id=ctx.user_id if actor_kind == "user" else None, 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, actor_name=ctx.actor_name or None,
action=action, action=action,
object_type=object_type, object_type=object_type,

View File

@@ -1,8 +1,10 @@
from __future__ import annotations 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 sqlalchemy import select
from bid_doctor.billing import service 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 def get_quota(ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s: async with tenant_tx(ctx) as s:
return await service.overview(s, ctx) 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()))

View File

@@ -28,6 +28,7 @@ class Subscription(Base):
seats: Mapped[int] = mapped_column(server_default=FetchedValue()) seats: Mapped[int] = mapped_column(server_default=FetchedValue())
starts_at: Mapped[Ts] starts_at: Mapped[Ts]
ends_at: Mapped[Ts | None] ends_at: Mapped[Ts | None]
activation_request_id: Mapped[UuidCol | None]
class QuotaAccount(Base): class QuotaAccount(Base):
@@ -56,4 +57,37 @@ class QuotaEntry(Base):
project_id: Mapped[UuidCol | None] project_id: Mapped[UuidCol | None]
reason: Mapped[str | None] reason: Mapped[str | None]
actor_user_id: Mapped[UuidCol | None] actor_user_id: Mapped[UuidCol | None]
actor_ops_id: Mapped[UuidCol | None] = mapped_column(default=None)
created_at: Mapped[DbTs] 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]

View File

@@ -5,17 +5,27 @@
from __future__ import annotations from __future__ import annotations
import base64
import re
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import UUID 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 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.db import TenantContext
from bid_doctor.core.errors import AppError from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7 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: 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]: async def current_plan(session: AsyncSession, ctx: TenantContext) -> tuple[Subscription, Plan]:
"""当前这一期:生效中的;没有(专业版到期、还没续费)时取最近过期的那一期,只读(决策 M6-5)。"""
row = ( row = (
await session.execute( await session.execute(
select(Subscription, Plan) select(Subscription, Plan)
.join(Plan, (Plan.code == Subscription.plan_code) & (Plan.version == Subscription.plan_version)) .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() ).one()
return row[0], row[1] 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( async def hold(
session: AsyncSession, ctx: TenantContext, *, unit: str, ref_type: str, ref_id: UUID, project_id: UUID session: AsyncSession, ctx: TenantContext, *, unit: str, ref_type: str, ref_id: UUID, project_id: UUID
) -> UUID: ) -> UUID:
@@ -87,9 +105,37 @@ async def hold(
ref_id=ref_id, ref_id=ref_id,
project_id=project_id, project_id=project_id,
) )
await _alert(session, ctx, acct)
return entry.id 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: async def balance(session: AsyncSession, ctx: TenantContext, unit: str) -> int:
acct = ( acct = (
await session.execute( await session.execute(
@@ -170,7 +216,7 @@ async def _append(
kind=kind, kind=kind,
amount=amount, amount=amount,
idempotency_key=key, idempotency_key=key,
actor_user_id=ctx.user_id, actor_user_id=ctx.user_id if ctx.user_id.int else None, # 定时任务、运营操作没有用户
**fields, **fields,
) )
session.add(entry) session.add(entry)
@@ -181,20 +227,24 @@ async def _append(
async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]: async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
sub, plan = await current_plan(session, ctx) sub, plan = await current_plan(session, ctx)
# 每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 预扣数 − 退回数 − 结算数。 # 本期 = 这一期订阅的起止。每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 已用 − 结算数。
# TODO(专业版续费):按订阅周期切分「本期」;MVP 的免费版只有一个周期。 period = {"t": ctx.tenant_id, "s": sub.id, "start": sub.starts_at, "end": sub.ends_at}
# 本期的流水:发放按订阅算,用量按这一期的起止时间算
rows = ( rows = (
await session.execute( await session.execute(
text( text(
"SELECT a.unit, a.balance," "WITH e AS (SELECT * FROM quota_entry"
" coalesce(sum(e.amount) FILTER (WHERE e.kind = 'grant' AND e.subscription_id = :s), 0)," " 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 = 'hold'),"
" count(e.id) FILTER (WHERE e.kind = 'release')," " count(e.id) FILTER (WHERE e.kind = 'release'),"
" count(e.id) FILTER (WHERE e.kind = 'settle')" " count(e.id) FILTER (WHERE e.kind = 'settle')"
" FROM quota_account a LEFT JOIN quota_entry e ON e.account_id = a.id" " FROM quota_account a LEFT JOIN e ON e.account_id = a.id"
" WHERE a.tenant_id = :t GROUP BY a.unit, a.balance ORDER BY a.unit" " 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() ).all()
accounts: list[dict[str, Any]] = [] accounts: list[dict[str, Any]] = []
@@ -207,10 +257,48 @@ async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
"held": used - settles, "held": used - settles,
"period_granted": granted, "period_granted": granted,
"period_used": used, "period_used": used,
"period_released": releases,
"used_ratio": round(used / granted, 3) if granted else 0.0, "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]: 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, "name": plan.name,
"starts_at": sub.starts_at, "starts_at": sub.starts_at,
"ends_at": sub.ends_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,
)

View File

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

View File

@@ -223,6 +223,7 @@ async def request_cancel(s: AsyncSession, run_id: UUID) -> CheckRun:
raise AppError("job_finished") raise AppError("job_finished")
run.cancel_requested_at = datetime.now(UTC) run.cancel_requested_at = datetime.now(UTC)
await s.flush() await s.flush()
await s.refresh(run) # 同解析的取消:触发器改过的列过期了,转 API 时不能再懒加载
return run return run

View File

@@ -26,6 +26,15 @@ def main() -> None:
api.add_argument("--host", default="127.0.0.1") api.add_argument("--host", default="127.0.0.1")
api.add_argument("--port", type=int, default=8009) api.add_argument("--port", type=int, default=8009)
api.add_argument("--reload", action="store_true") 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("worker", help="启动后台任务进程")
sub.add_parser("migrate", help="把数据库升级到最新版本") sub.add_parser("migrate", help="把数据库升级到最新版本")
args = parser.parse_args() args = parser.parse_args()
@@ -34,6 +43,21 @@ def main() -> None:
import uvicorn import uvicorn
uvicorn.run("bid_doctor.app:create_app", factory=True, host=args.host, port=args.port, reload=args.reload) 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": elif args.cmd == "worker":
from bid_doctor.worker import run_worker from bid_doctor.worker import run_worker

View File

@@ -20,6 +20,8 @@ class Settings(BaseSettings):
# api / worker 用 bd_app(受行级安全策略约束);迁移用 bd_owner # api / worker 用 bd_app(受行级安全策略约束);迁移用 bd_owner
database_url: str = "postgresql+psycopg://bd_app:dev-app@127.0.0.1:54329/bid_doctor" 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" 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" valkey_url: str = "redis://127.0.0.1:63799/0"
gotenberg_url: str = "http://127.0.0.1:3109" gotenberg_url: str = "http://127.0.0.1:3109"
@@ -49,6 +51,16 @@ class Settings(BaseSettings):
# 每次调用的输出上限(token)。输出用到 92% 以上就按截断处理、拆小重跑(ai/providers/base.near_limit) # 每次调用的输出上限(token)。输出用到 92% 以上就按截断处理、拆小重跑(ai/providers/base.near_limit)
ai_max_output_tokens: int = 8192 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 upload_part_size: int = 8 * 1024 * 1024
download_url_ttl_s: int = 300 download_url_ttl_s: int = 300
@@ -69,6 +81,10 @@ class Settings(BaseSettings):
raise ValueError("生产环境不能使用假模型") raise ValueError("生产环境不能使用假模型")
if not self.cookie_secure: if not self.cookie_secure:
raise ValueError("生产环境必须开启 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 return self

View File

@@ -92,3 +92,43 @@ def verify_download(payload: str, exp: int, sig: str) -> bool:
return False return False
mac = hmac.new(_key(get_settings().secret_key, "download"), f"{payload}|{exp}".encode(), hashlib.sha256).digest() 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) 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 秒

View File

@@ -66,3 +66,21 @@ async def anonymous_tx() -> AsyncGenerator[AsyncSession]:
async def set_tenant(session: AsyncSession, tenant_id: UUID) -> None: 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)}) 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

View File

@@ -16,6 +16,8 @@ CODES: dict[str, tuple[int, str]] = {
"session_revoked": (401, "登录已失效,请重新登录"), "session_revoked": (401, "登录已失效,请重新登录"),
"csrf_failed": (403, "请求校验失败,请刷新页面后重试"), "csrf_failed": (403, "请求校验失败,请刷新页面后重试"),
"forbidden": (403, "没有权限进行这个操作"), "forbidden": (403, "没有权限进行这个操作"),
"ops_ip_denied": (403, "这个地址不能访问运营后台"),
"ops_login_failed": (401, "用户名、密码或动态验证码不对"),
"not_found": (404, "没有找到"), "not_found": (404, "没有找到"),
"phone_not_registered": (404, "这个手机号还没有注册"), "phone_not_registered": (404, "这个手机号还没有注册"),
"phone_already_registered": (409, "这个手机号已经注册"), "phone_already_registered": (409, "这个手机号已经注册"),
@@ -26,6 +28,9 @@ CODES: dict[str, tuple[int, str]] = {
"job_already_running": (409, "任务正在进行"), "job_already_running": (409, "任务正在进行"),
"job_finished": (409, "任务已经结束"), "job_finished": (409, "任务已经结束"),
"project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"), "project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"),
"plan_expired": (409, "套餐已到期,数据只读;续费到账后恢复"),
"activation_pending": (409, "已经有一份待处理的开通申请"),
"tenant_name_mismatch": (422, "企业名称不一致,请照上面的名称输入"),
"not_superseded": (409, "这一条没有待确认的补遗更新"), "not_superseded": (409, "这一条没有待确认的补遗更新"),
"no_pending_value": (409, "这个字段没有待确认的补遗新值"), "no_pending_value": (409, "这个字段没有待确认的补遗新值"),
"document_role_not_parsable": (409, "这类文件不需要单独解析"), "document_role_not_parsable": (409, "这类文件不需要单独解析"),

View File

@@ -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 from __future__ import annotations
import os import os
import threading
import time import time
import uuid 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: def uuid7() -> uuid.UUID:
global _last
ms = time.time_ns() // 1_000_000 ms = time.time_ns() // 1_000_000
rand = int.from_bytes(os.urandom(10), "big") value = _stamp((ms & 0xFFFF_FFFF_FFFF) << 80 | int.from_bytes(os.urandom(10), "big"))
value = (ms & 0xFFFF_FFFF_FFFF) << 80 | rand with _lock:
value = (value & ~(0xF << 76)) | (0x7 << 76) # 版本 7 if value <= _last: # 同一毫秒(或时钟回拨):排在上一个后面,只动最低的 62 位随机部分
value = (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 4122 变体 value = _last + 1
_last = value
return uuid.UUID(int=value) return uuid.UUID(int=value)

View File

@@ -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.crypto import KEK_REF, new_data_key, unwrap_data_key
from bid_doctor.core.db import TenantContext from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7 from bid_doctor.core.ids import uuid7
@@ -26,6 +27,17 @@ async def data_key(session: AsyncSession, ctx: TenantContext, project_id: UUID |
).first() ).first()
if row: if row:
return unwrap_data_key(row[0]) 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() plain, wrapped = new_data_key()
await session.execute( await session.execute(
text( text(
@@ -45,3 +57,13 @@ async def destroy_project_key(session: AsyncSession, ctx: TenantContext, project
), ),
{"t": ctx.tenant_id, "p": project_id}, {"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},
)

View File

@@ -69,3 +69,7 @@ def upload_prefix(tenant_id: object, upload_id: object) -> str:
def qualification_prefix(tenant_id: object, qualification_id: object) -> str: def qualification_prefix(tenant_id: object, qualification_id: object) -> str:
return f"t/{tenant_id}/q/{qualification_id}" return f"t/{tenant_id}/q/{qualification_id}"
def tenant_prefix(tenant_id: object) -> str:
return f"t/{tenant_id}"

View File

@@ -188,6 +188,7 @@ async def request_cancel(s: AsyncSession, job_id: UUID) -> ParseJob:
raise AppError("job_finished") raise AppError("job_finished")
job.cancel_requested_at = datetime.now(UTC) job.cancel_requested_at = datetime.now(UTC)
await s.flush() await s.flush()
await s.refresh(job) # 触发器改过的列(updated_at)过期了,转 API 时不能再懒加载
return job return job

View File

@@ -5,7 +5,7 @@ from typing import Any, Literal
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from pydantic import BaseModel, Field 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 from bid_doctor.identity.deps import Auth, clear_session_cookies, set_session_cookies
router = APIRouter(tags=["auth", "me"]) router = APIRouter(tags=["auth", "me"])
@@ -79,3 +79,24 @@ async def logout(p: Auth, response: Response) -> None:
@router.get("/me") @router.get("/me")
async def get_me(p: Auth) -> dict[str, Any]: async def get_me(p: Auth) -> dict[str, Any]:
return await service.me(p) 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)

View File

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

View File

@@ -7,15 +7,27 @@ from typing import Annotated
from fastapi import Depends, Request, Response 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.config import get_settings
from bid_doctor.core.crypto import new_token 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.core.errors import AppError
from bid_doctor.identity.service import Principal, resolve, why_invalid from bid_doctor.identity.service import Principal, resolve, why_invalid
SESSION_COOKIE = "bd_session" SESSION_COOKIE = "bd_session"
CSRF_COOKIE = "bd_csrf" CSRF_COOKIE = "bd_csrf"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} 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: async def current_principal(request: Request) -> Principal:
@@ -34,9 +46,19 @@ async def current_principal(request: Request) -> Principal:
if p is None: if p is None:
code, reason = await why_invalid(token) code, reason = await why_invalid(token)
raise AppError(code, **({"reason": reason} if reason else {})) 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 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: async def current_ctx(p: Annotated[Principal, Depends(current_principal)]) -> TenantContext:
return p.ctx return p.ctx

View File

@@ -1,15 +1,18 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Any 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 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 import events
from bid_doctor.core.db import tenant_tx from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx from bid_doctor.identity.deps import Ctx
from bid_doctor.notification.models import Notification from bid_doctor.notification.models import Notification
from bid_doctor.project.models import Project
router = APIRouter(tags=["notifications"]) 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") @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: 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: if unread:
q = q.where(Notification.read_at.is_(None)) 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 = ( n = (
await s.execute( await s.execute(
select(func.count()) 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)) .where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
) )
).scalar_one() ).scalar_one()
titles = { more = len(rows) > limit
"parse_succeeded": "招标文件解析完成", rows = rows[:limit]
"parse_failed": "招标文件解析失败,额度已退回",
"check_succeeded": "投标文件检查完成",
"check_failed": "投标文件检查没有完成,不扣额度",
"report_succeeded": "检查报告已生成",
"report_failed": "检查报告生成失败",
}
return { return {
"unread_count": n, "unread_count": n,
"next_cursor": None, "next_cursor": str(rows[-1][0].id) if more else None,
"items": [ "items": [
{ {
"id": r.id, "id": r.id,
"kind": r.kind, "kind": r.kind,
"title": titles.get(r.kind, r.kind), "title": _title(r.kind, r.payload or {}),
"project_id": r.project_id, "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, "read_at": r.read_at,
"created_at": r.created_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()))

View File

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

View File

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

View File

@@ -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]}

View File

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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations 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 sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, DbTs, Ts, UuidCol from bid_doctor.core.columns import PK, DbTs, Ts, UuidCol
@@ -25,3 +26,29 @@ class Feedback(Base):
created_by: Mapped[UuidCol] created_by: Mapped[UuidCol]
created_at: Mapped[DbTs] created_at: Mapped[DbTs]
updated_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]

View File

@@ -76,6 +76,12 @@ async def get_project(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
return await workbench.detail(s, ctx, project_id) 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") @router.post("/projects/{project_id}/transitions")
async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) -> dict[str, Any]: async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s: async with tenant_tx(ctx) as s:

View File

@@ -5,11 +5,12 @@ from __future__ import annotations
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from sqlalchemy import func, select from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing 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.authz import Permission, require
from bid_doctor.core.db import TenantContext from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError from bid_doctor.core.errors import AppError
@@ -52,6 +53,45 @@ async def get(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project:
return p 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: def ensure_editable(p: Project) -> None:
if p.status in ("submitted", "archived"): if p.status in ("submitted", "archived"):
raise AppError("project_read_only") raise AppError("project_read_only")

View File

@@ -6,6 +6,7 @@ worker 直连数据库(Procrastinate 靠 LISTEN/NOTIFY 发现新任务,不
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
import procrastinate 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)) 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 def run_worker() -> None:
async with app.open_async(): async with app.open_async():
await app.run_worker_async(concurrency=4, install_signal_handlers=True) await app.run_worker_async(concurrency=4, install_signal_handlers=True)

View File

@@ -19,6 +19,7 @@ os.environ.update(
"BD_ENV": "test", "BD_ENV": "test",
"BD_DATABASE_URL": f"postgresql+psycopg://bd_app:dev-app@{PG}/bid_doctor_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_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_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_STORAGE_DIR": tempfile.mkdtemp(prefix="bd-test-objects-"),
"BD_AI_PROVIDER": "fake", "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: 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("DROP DATABASE IF EXISTS bid_doctor_test WITH (FORCE)")
conn.execute("CREATE DATABASE bid_doctor_test") 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) @pytest.fixture(scope="session", autouse=True)

View File

@@ -0,0 +1,205 @@
"""M6 剩下的 P0(m6-plan):额度流水、80% / 100% 提醒、开通和续费、到期只读、删除项目、注销账号、通知已读。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
import httpx
import pytest
from sqlalchemy import text
from bid_doctor.billing import service as billing
from bid_doctor.billing import tasks
from bid_doctor.core.db import TenantContext, tenant_tx
from tests.conftest import User, balance, project_with_tender, register, run_parse
OPS = uuid4() # 运营后台还没做:直接调 service,记一个运营编号
INVOICE = {
"title": "测试集成公司",
"tax_no": "91320100MA1XXXXX0Y",
"invoice_type": "general",
"contact_name": "张工",
"contact_phone": "13800000000",
}
def ctx_of(u: User) -> TenantContext:
return TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0), actor_name="系统")
async def activate(u: User, request_id: str) -> None:
async with tenant_tx(ctx_of(u)) as s:
await billing.activate(s, ctx_of(u), UUID(request_id), ops_id=OPS)
async def entries(u: User, **params: Any) -> list[dict[str, Any]]:
r = await u.get("/quota/entries", params=params)
assert r.status_code == 200, r.text
return r.json()["items"]
async def test_ledger_alerts_and_pages(user: User) -> None:
"""流水:任务能对上项目和用途,余额逐条可核;用到 80%、100% 各提醒一次(免费版 3 次)。"""
project_id, doc_id = await project_with_tender(user)
assert (await run_parse(user, doc_id))["status"] == "succeeded"
rows = await entries(user)
assert [(r["kind"], r["amount"], r["balance_after"]) for r in rows] == [
("settle", 0, 2),
("hold", -1, 2),
("grant", 3, 3),
]
hold = rows[1]
assert hold["project"] == {"id": project_id, "name": hold["project"]["name"], "deleted": False}
assert hold["ref"]["type"] == "parse_job" and hold["ref"]["label"] == "招标文件"
assert rows[0]["hold_entry_id"] == hold["id"] and rows[2]["reason"] == "注册赠送"
# 分页:每页 1 条,接着上一页往后
first = (await user.get("/quota/entries", params={"limit": 1})).json()
second = (await user.get("/quota/entries", params={"limit": 1, "cursor": first["next_cursor"]})).json()
assert first["items"][0]["kind"] == "settle" and second["items"][0]["kind"] == "hold"
# 再解析两次:第 3 次时余额 0,80% 和 100% 一起提醒(每档只一次)
for _ in range(2):
r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
from bid_doctor.document.pipeline import runner
await runner.run(UUID(user.tenant_id), UUID(r.json()["id"]))
assert await balance(user) == 0
kinds = [n["kind"] for n in (await user.get("/notifications")).json()["items"]]
assert kinds.count("quota_80") == 1 and kinds.count("quota_100") == 1
q = (await user.get("/quota")).json()
assert q["usage"]["tender"] == 3 and q["accounts"][0]["period_used"] == 3
async def test_activation_renewal_expiry_read_only(app_client: httpx.AsyncClient) -> None:
u = await register(app_client, "开通测试公司")
bad = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "tax_no": "123"}})
assert bad.status_code == 422
special = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "invoice_type": "special"}})
assert special.status_code == 422 and special.json()["code"] == "validation_failed"
r = await u.post("/activation-requests", json={"invoice_info": INVOICE})
assert r.status_code == 201, r.text
req = r.json()
assert req["status"] == "submitted" and req["request_no"].startswith("KT-") and req["payment"]["payee_name"]
assert (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()["code"] == "activation_pending"
assert (await u.get("/quota")).json()["pending_request"]["id"] == req["id"]
# 运营开通:免费版剩下的 3 次清零,发放专业版 500 次
await activate(u, req["id"])
me = (await u.get("/me")).json()
assert me["plan"]["code"] == "pro" and me["plan"]["read_only"] is False
rows = await entries(u)
assert [(x["kind"], x["amount"]) for x in rows[:2]] == [("grant", 500), ("expire", -3)]
assert await balance(u) == 500
ends = datetime.fromisoformat(me["plan"]["ends_at"])
notes = [n["kind"] for n in (await u.get("/notifications")).json()["items"]]
assert "subscription_activated" in notes
# 续费:当前一期还没到期,新一期排队,从旧一期结束时开始
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
await activate(u, r2["id"])
q = (await u.get("/quota")).json()
assert q["renewal"] and datetime.fromisoformat(q["renewal"]["starts_at"]) == ends and await balance(u) == 500
# 到期前 30 天、7 天提醒各一次;重复跑不重复提醒(续过费的不提醒,先把排队的一期撤掉)
async with tenant_tx(ctx_of(u)) as s:
await s.execute(text("UPDATE subscription SET status = 'cancelled' WHERE status = 'scheduled'"))
for days in (25, 25, 5):
await tasks.tick(ends - timedelta(days=days))
reminders = [n for n in (await u.get("/notifications")).json()["items"] if n["kind"] == "subscription_expiring"]
assert [n["title"] for n in reminders] == ["专业版还有 5 天到期", "专业版还有 25 天到期"]
# 到期:余额清零,只读——新建项目被拒,开通申请、通知已读照常
await tasks.tick(ends + timedelta(minutes=1))
me = (await u.get("/me")).json()
assert me["plan"]["read_only"] is True and await balance(u) == 0
assert (await u.post("/projects", json={})).json()["code"] == "plan_expired"
assert (await u.post("/notifications/read", json={})).status_code == 204
assert (await u.get("/notifications", params={"unread": True})).json()["unread_count"] == 0
r3 = await u.post("/activation-requests", json={"invoice_info": INVOICE})
assert r3.status_code == 201
await activate(u, r3.json()["id"]) # 过期后续费:马上开始新一期
assert (await u.get("/me")).json()["plan"]["read_only"] is False and await balance(u) == 500
async def test_delete_project(user: User) -> None:
project_id, doc_id = await project_with_tender(user)
queued = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
assert queued.status_code == 201
blocked = await user.delete(f"/projects/{project_id}")
assert blocked.status_code == 409 and blocked.json()["code"] == "job_already_running"
assert (await user.post(f"/parse-jobs/{queued.json()['id']}/cancel")).status_code in (200, 202)
from bid_doctor.document.pipeline import runner
await runner.run(UUID(user.tenant_id), UUID(queued.json()["id"])) # 取消:预扣退回
assert (await run_parse(user, doc_id))["status"] == "succeeded"
r = await user.delete(f"/projects/{project_id}")
assert r.status_code == 204, r.text
assert (await user.get(f"/projects/{project_id}")).status_code == 404
# 流水还在,项目标「已删除」;没有净扣点的任务(取消的那次)照样对得上
rows = [x for x in await entries(user) if x["project"]]
assert rows and all(x["project"]["deleted"] and x["project"]["name"] is None for x in rows)
assert sum(x["amount"] for x in rows) == -1
async with tenant_tx(ctx_of(user)) as s:
key = (await s.execute(text("SELECT wrapped_key FROM data_key WHERE project_id = :p"), {"p": project_id})).one()
audit = (
await s.execute(
text(
"SELECT count(*) FILTER (WHERE before IS NOT NULL OR after IS NOT NULL), count(*)"
" FROM audit_event WHERE project_id = :p"
),
{"p": project_id},
)
).one()
from bid_doctor.core import keys
from bid_doctor.core.errors import AppError
with pytest.raises(AppError) as err: # 删除后不再建新密钥(还在跑的任务写不回文件)
await keys.data_key(s, ctx_of(user), UUID(project_id))
assert err.value.code == "not_found"
assert key[0] is None and audit[0] == 0 and audit[1] >= 2
async def test_close_tenant(app_client: httpx.AsyncClient) -> None:
u = await register(app_client, "注销测试公司")
project_id, doc_id = await project_with_tender(u)
assert (await run_parse(u, doc_id))["status"] == "succeeded"
from bid_doctor.core.crypto import phone_lookup_key
from bid_doctor.core.valkey import valkey
await valkey().delete(f"sms:rate:{phone_lookup_key(u.phone)}") # 注册时刚发过一条,60 秒内不能再发
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code == 202
wrong = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "别的公司"})
assert wrong.status_code == 422 and wrong.json()["code"] == "tenant_name_mismatch"
bad_code = await u.post("/tenant/close", json={"sms_code": "111111", "confirm_tenant_name": "注销测试公司"})
assert bad_code.json()["code"] == "sms_code_invalid"
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code in (202, 429)
r = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "注销测试公司"})
assert r.status_code == 202, r.text
assert (await u.get("/me")).status_code == 401
from bid_doctor.identity import closure
await closure.finish(UUID(u.tenant_id))
await closure.finish(UUID(u.tenant_id)) # 重复执行没事
async with tenant_tx(ctx_of(u)) as s:
left = (
await s.execute(
text(
"SELECT (SELECT count(*) FROM project),"
" (SELECT count(*) FROM data_key WHERE wrapped_key IS NOT NULL),"
" (SELECT count(*) FROM quota_entry), (SELECT status FROM tenant),"
" (SELECT count(*) FROM audit_event WHERE actor_name IS NOT NULL OR after IS NOT NULL)"
)
)
).one()
assert left[0] == 0 and left[1] == 0 and left[2] > 0 and left[3] == "closed" and left[4] == 0
# 手机号可以重新注册(新企业)
assert (await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "register"})).status_code in (
202,
429,
)
del project_id

View File

@@ -0,0 +1,181 @@
"""运营后台(FR-M6-09,m6-plan §2,决策 M6-2 / M6-4 / M6-7):独立进程、用户名 + 密码 + TOTP、IP 白名单;
跨企业只读走 bd_ops,对某个企业的操作走它自己的租户事务并写审计;不显示业务内容。"""
from __future__ import annotations
import base64
import itertools
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from bid_doctor.core.crypto import totp
from bid_doctor.core.db import TenantContext, ops_tx, tenant_tx
from tests.conftest import User, project_with_tender, register, run_parse
from tests.test_billing_flow import INVOICE, entries
_names = itertools.count(1)
async def ops_client(ip: str = "127.0.0.1") -> httpx.AsyncClient:
from bid_doctor.ops.app import create_ops_app
transport = httpx.ASGITransport(app=create_ops_app(), client=(ip, 51000))
return httpx.AsyncClient(transport=transport, base_url="http://ops/ops/v1")
async def new_admin() -> tuple[str, bytes]:
from bid_doctor.ops.auth import create_admin
username = f"ops{next(_names)}"
uri = await create_admin(username, "运营小王", "correct horse battery")
b32 = parse_qs(urlsplit(uri).query)["secret"][0]
return username, base64.b32decode(b32 + "=" * (-len(b32) % 8))
@pytest.fixture
async def ops() -> AsyncIterator[httpx.AsyncClient]:
username, secret = await new_admin()
async with await ops_client() as c:
r = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
assert r.status_code == 200, r.text
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
yield c
async def test_login_needs_password_totp_and_allowed_ip() -> None:
username, secret = await new_admin()
async with await ops_client() as c:
bad = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": "000000"}
)
assert bad.status_code == 401 and bad.json()["code"] == "ops_login_failed"
assert (await c.get("/me")).status_code == 401
async with await ops_client("10.1.2.3") as far:
denied = await far.post("/auth/login", json={"username": username, "password": "x", "totp": totp(secret)})
assert denied.status_code == 403 and denied.json()["code"] == "ops_ip_denied"
# 用户端的 bd_app 读不到运营账号表
async with tenant_tx(
TenantContext(tenant_id=__import__("uuid").uuid4(), user_id=__import__("uuid").UUID(int=0))
) as s:
with pytest.raises(ProgrammingError):
await s.execute(text("SELECT 1 FROM ops_admin"))
async def test_tenants_activation_and_adjust(ops: httpx.AsyncClient, app_client: httpx.AsyncClient) -> None:
u: User = await register(app_client, "运营测试甲公司")
project_id, doc_id = await project_with_tender(u)
assert (await run_parse(u, doc_id))["status"] == "succeeded"
found = (await ops.get("/tenants", params={"q": "运营测试甲"})).json()["items"]
assert [t["name"] for t in found] == ["运营测试甲公司"] and found[0]["balance"] == 2
assert (await ops.get("/tenants", params={"q": u.phone})).json()["items"][0]["id"] == u.tenant_id
detail = (await ops.get(f"/tenants/{u.tenant_id}")).json()
assert detail["projects"] == 1 and detail["jobs"]["parse_job"] == {"succeeded": 1}
assert "未命名项目" not in str(detail) and project_id not in str(detail) # 不显示项目名称和编号
# 开通:用户提交 → 运营标记到账 → 开通 → 登记发票;全程写审计(操作人是运营)
req = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
listed = (await ops.get("/activation-requests", params={"status": "submitted"})).json()["items"]
assert req["id"] in [x["id"] for x in listed]
assert (await ops.get("/tenants", params={"q": req["request_no"]})).json()["items"][0]["id"] == u.tenant_id
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 409
assert (await ops.post(f"/activation-requests/{req['id']}/paid")).status_code == 200
r = await ops.post(f"/activation-requests/{req['id']}/activate", json={})
assert r.status_code == 200 and r.json()["subscription"]["status"] == "active", r.text
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 200
assert (await u.get("/me")).json()["plan"]["code"] == "pro"
# 再申请一份,运营退回(要写原因),用户看得到原因
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
assert (await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": ""})).status_code == 422
assert (
await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": "抬头和转账单位不一致"})
).status_code == 200
mine = (await u.get("/activation-requests")).json()["items"]
assert mine[0]["status"] == "rejected" and mine[0]["ops_note"] == "抬头和转账单位不一致"
# 调额度:原因必填;不能扣成负数;流水里看得到
assert (
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": ""})
).status_code == 422
too_much = await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": -9999, "reason": "测试扣减"})
assert too_much.status_code == 422
assert (
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": "试用补偿"})
).status_code == 201
adj = (await entries(u))[0]
assert (adj["kind"], adj["amount"], adj["reason"]) == ("adjust", 20, "试用补偿")
events = (await ops.get("/audit-events", params={"tenant_id": u.tenant_id, "action": "ops."})).json()["items"]
actions = {e["action"] for e in events}
assert {
"ops.tenant.view",
"ops.activation.paid",
"ops.activation.activate",
"ops.activation.reject",
"ops.activation.invoice",
"ops.quota.adjust",
} <= actions
assert all(e["actor_kind"] == "ops" and e["actor_name"] == "运营小王" for e in events)
async def test_feedback_and_failed_jobs_without_content(ops: httpx.AsyncClient, user: User) -> None:
from tests.test_check_flow import checked_project
project_id, _, run = await checked_project(user)
finding = (await user.get(f"/check-runs/{run['id']}/findings")).json()["items"][0]
r = await user.post(
"/feedback",
json={
"project_id": project_id,
"object_type": "finding",
"object_id": finding["id"],
"kind": "false_positive",
"note": "这不是问题(误报)",
},
)
assert r.status_code == 201
items = (await ops.get("/feedback", params={"kind": "false_positive"})).json()["items"]
mine = next(x for x in items if x["note"] == "这不是问题(误报)")
assert mine["title"] == finding["title"] and mine["rule_set_version"] and mine["pages"]
assert "quote" not in str(mine) and "text" not in mine # 不带原文
async with ops_tx() as s: # bd_ops 只读:改不了业务表
with pytest.raises(ProgrammingError):
await s.execute(text("UPDATE project SET name = 'x'"))
assert isinstance((await ops.get("/failed-jobs")).json()["items"], list)
async def test_every_endpoint_checks_permission() -> None:
"""角色只留结构:现在只有管理员(全部权限);没定义的角色什么权限都没有,接口一律 403,/me 照常给出权限点。"""
username, secret = await new_admin()
async with ops_tx() as s:
await s.execute(text("UPDATE ops_admin SET role = 'sales' WHERE username = :u"), {"u": username})
async with await ops_client() as c:
r = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
assert r.status_code == 200 and r.json()["permissions"] == [] and r.json()["role"] == "sales"
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
for path in ("/tenants", "/activation-requests", "/feedback", "/failed-jobs", "/audit-events"):
assert (await c.get(path)).status_code == 403, path
assert (await c.get("/summary")).json() == {"open_requests": 0, "failed_jobs_7d": 0, "feedback_7d": 0}
async with await ops_client() as c: # 管理员
username, secret = await new_admin()
me = (
await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
).json()
assert (
me["role"] == "admin"
and me["role_label"] == "管理员"
and "audit.view" in me["permissions"]
and me["env"] == "test"
)