feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。 用户端: - 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV) - 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒 - 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick) - 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码) - 通知铃铛、账号页 运营后台: - 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单 - 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容 - 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签) - 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤 数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。 另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -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: |
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -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
13
apps/admin/index.html
Normal 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
25
apps/admin/package.json
Normal 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
53
apps/admin/src/App.vue
Normal 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>
|
||||||
289
apps/admin/src/OpsSidebar.vue
Normal file
289
apps/admin/src/OpsSidebar.vue
Normal 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
15
apps/admin/src/admin.css
Normal 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
155
apps/admin/src/api.ts
Normal 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
11
apps/admin/src/main.ts
Normal 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
49
apps/admin/src/nav.ts
Normal 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);
|
||||||
|
}
|
||||||
50
apps/admin/src/pages/AuditPage.vue
Normal file
50
apps/admin/src/pages/AuditPage.vue
Normal 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>
|
||||||
48
apps/admin/src/pages/FailedJobsPage.vue
Normal file
48
apps/admin/src/pages/FailedJobsPage.vue
Normal 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>
|
||||||
63
apps/admin/src/pages/FeedbackPage.vue
Normal file
63
apps/admin/src/pages/FeedbackPage.vue
Normal 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>
|
||||||
82
apps/admin/src/pages/LoginPage.vue
Normal file
82
apps/admin/src/pages/LoginPage.vue
Normal 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>
|
||||||
126
apps/admin/src/pages/RequestsPage.vue
Normal file
126
apps/admin/src/pages/RequestsPage.vue
Normal 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>
|
||||||
143
apps/admin/src/pages/TenantPage.vue
Normal file
143
apps/admin/src/pages/TenantPage.vue
Normal 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>
|
||||||
60
apps/admin/src/pages/TenantsPage.vue
Normal file
60
apps/admin/src/pages/TenantsPage.vue
Normal 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
20
apps/admin/src/router.ts
Normal 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
20
apps/admin/tsconfig.json
Normal 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
25
apps/admin/vite.config.ts
Normal 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 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
192
apps/web/src/components/ActivationDialog.vue
Normal file
192
apps/web/src/components/ActivationDialog.vue
Normal 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>
|
||||||
@@ -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 />
|
||||||
|
|||||||
138
apps/web/src/components/NotificationBell.vue
Normal file
138
apps/web/src/components/NotificationBell.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
154
apps/web/src/pages/AccountPage.vue
Normal file
154
apps/web/src/pages/AccountPage.vue
Normal 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>
|
||||||
287
apps/web/src/pages/QuotaPage.vue
Normal file
287
apps/web/src/pages/QuotaPage.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|
||||||
|
|||||||
114
docs/architecture/m6-plan.md
Normal file
114
docs/architecture/m6-plan.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# M6 剩下的 P0:额度流水、套餐开通、数据删除、运营后台(实现计划 v0.1)
|
||||||
|
|
||||||
|
依据:FR-M6-03 / 05 / 06 / 07 / 09(`docs/analysis/03-functional-requirements.md`),J4、J5(`02-user-journeys.md`),G6、G8、G10,决策 Q9、Q13、P1、DM4;表结构已在基线迁移里(`data-model.md` §4、§6),用户端接口已在 `openapi.yaml` 里定义;运营后台的接口和选型「做到时再定」(架构 §3.11、§4.3)。
|
||||||
|
|
||||||
|
## 0. 现状
|
||||||
|
|
||||||
|
| 方面 | 已有 | 还没有 |
|
||||||
|
|---|---|---|
|
||||||
|
| 额度 | 注册发放、预扣 / 结算 / 退回 / 免扣、`GET /quota`、顶栏额度(≥ 80% 赭色) | 流水接口和额度页、80% / 100% 提醒、人工调整、过期 |
|
||||||
|
| 套餐 | 免费版注册即开通、`GET /plans`、专业版套餐(解析 500,D2 待定) | 开通 / 续费申请、运营开通、到期处理 |
|
||||||
|
| 删除 | 资质删除(QL6) | 删除项目、注销账号 |
|
||||||
|
| 通知 | 解析、检查、报告完成写通知;事件流 | 前端通知入口、标为已读 |
|
||||||
|
| 运营后台 | 表:`ops_admin`、`ops_session`;角色 `bd_ops`(文档里有,部署脚本里还没建);反馈写入 | 进程、登录、接口、前端,全部没有 |
|
||||||
|
|
||||||
|
另外发现一处冲突:原型 `project.html` 的删除写「30 天内可以在回收站恢复」,而 G10、DM4(已确认)是删除时销毁项目数据密钥、彻底删除。按决策优先,见 M6-1。
|
||||||
|
|
||||||
|
## 1. 用户端
|
||||||
|
|
||||||
|
### 1.1 额度页(FR-M6-03,原型 `quota.html`)
|
||||||
|
|
||||||
|
- 侧栏账户区(头像、套餐标签)点开进「额度与套餐」页;顶栏额度改成链接,也进这个页。
|
||||||
|
- 页面:本期额度卡(剩余 / 已用 / 退回,期间起止)→ 扣点规则表(原型原样)→ 按功能分布(读标招标文件、补遗和澄清、查标、免扣次数)→ 本期流水。
|
||||||
|
- 流水:`GET /quota/entries`,按单位筛选、按时间倒序分页;每行写时间、类型(发放 / 预扣 / 结算 / 退回 / 免扣 / 过期 / 调整)、数量(+ / −,墨色,Q1)、余额、关联项目 · 任务(「招标文件」「第 2 次检查」;项目删了写「已删除的项目」和任务编号,G10)、说明(调整原因、失败原因、「距上次扣额度的检查不到 24 小时」)。导出 CSV 在前端生成。
|
||||||
|
- 提醒:预扣后余额跨过 80%、100%(按本期发放算)时写站内通知,`quota_alert` 保证每期每档只提醒一次。
|
||||||
|
|
||||||
|
### 1.2 开通与续费(FR-M6-05,G8)
|
||||||
|
|
||||||
|
- 免费版:额度页和顶栏「额度用完」提示里有「开通专业版」;专业版:到期前 30 天起显示「续费」。
|
||||||
|
- 申请对话框(原型):企业名称(注册时的,只读)、发票类型(普票 / 专票)、纳税人识别号、联系人、手机号;专票再加地址电话、开户行账号。`POST /activation-requests`,不扣额度、不用先付款。
|
||||||
|
- 提交后页面顶部显示申请单号、状态(等待对公转账 / 已到账 / 已开通 / 已退回)、收款信息(收款单位、开户行、账号,从配置读 `BD_PAYEE_*`)和「转账备注写申请单号」。
|
||||||
|
- 运营确认到账并开通:建专业版订阅、发放额度(流水「发放」,说明写申请单号),发站内通知;之后填发票号。
|
||||||
|
- 续费:当前专业版还没到期时,新一期从旧一期结束时开始;旧一期剩下的额度到期清零(「本期没用完的不结转」,原型)。
|
||||||
|
|
||||||
|
### 1.3 到期(FR-M6-05)
|
||||||
|
|
||||||
|
- worker 每天跑一次定时任务(Procrastinate 周期任务):到期的订阅改「已过期」、记「过期」流水把余额清零;排队的下一期到点发放。
|
||||||
|
- 提前 30 天、7 天、到期当天各发一次站内通知。
|
||||||
|
- 过期后:数据保留,只读(M6-5)。
|
||||||
|
|
||||||
|
### 1.4 删除项目(J4、G10、data-model §6.1)
|
||||||
|
|
||||||
|
- 入口:项目头「更多」菜单(和归档放一起),已递交、已归档的也能删。
|
||||||
|
- 确认框:写明删掉什么、保留什么(额度流水、不含业务内容的审计),输入项目名称确认(原型)。
|
||||||
|
- `DELETE /projects/{id}`:同一事务里写审计、抹掉这个项目审计的内容、删项目行(级联)、销毁数据密钥;提交后后台任务删对象存储前缀。正在解析 / 检查的项目先取消任务再删(预扣退回)。
|
||||||
|
|
||||||
|
### 1.5 账号页与注销(J4、data-model §6.2)
|
||||||
|
|
||||||
|
- 「额度与套餐」页旁边一个「账号」页:企业名称、手机号(脱敏)、显示名、套餐;最下面「注销账号」。
|
||||||
|
- 注销:确认框写明后果(全部项目、资质库删除;开通申请和发票记录按财务要求保留;剩余额度作废、不退款),输入企业名称 + 短信验证码。`POST /tenant/close`:立即吊销会话、企业标「已注销」,项目按 §1.4 由后台任务逐个删。
|
||||||
|
|
||||||
|
### 1.6 通知入口
|
||||||
|
|
||||||
|
- 顶栏加铃铛(原型有),有未读时带圆点;点开是浮层列表(最近 30 条:解析 / 检查 / 报告完成、额度提醒、开通结果、到期提醒),点一条跳到相关页面并标为已读,「全部标为已读」。`POST /notifications/read`。
|
||||||
|
|
||||||
|
## 2. 运营后台(FR-M6-09)
|
||||||
|
|
||||||
|
### 2.1 形态
|
||||||
|
|
||||||
|
- **后端**:同一个 Python 包的第三种进程 `bid-doctor ops`(和 api、worker 一样的镜像),单独端口、路径前缀 `/ops/v1`,用 `bd_ops` 数据库角色(绕过行级安全策略);用户端 api 进程里不挂任何运营接口。部署时运营端口只对内网 / 白名单开放。
|
||||||
|
- **登录**:用户名 + 密码 + TOTP 二次验证;IP 白名单(`BD_OPS_ALLOWED_IPS`,空着时拒绝启动于生产环境);会话 8 小时;账号用命令行建(`bid-doctor ops-admin create`),没有自助注册。
|
||||||
|
- **前端**:`apps/admin`,Vue 3 + Vite,复用 `tokens.css` 和组件样式,不引入带样式的框架(M6-3);单独构建、单独部署,私有化不交付。
|
||||||
|
- **审计**:每个运营操作(包括查询企业详情)都写审计事件,操作人记运营账号。
|
||||||
|
|
||||||
|
### 2.2 页面和接口(最小版)
|
||||||
|
|
||||||
|
| 页面 | 做什么 | 接口(`/ops/v1`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 企业 | 按企业名称、手机号(用 HMAC 查)、申请单号搜;详情:成员(手机号脱敏)、套餐和到期日、各单位余额、项目数、最近任务;**不显示项目名称和任何业务内容** | `GET /tenants`、`GET /tenants/{id}` |
|
||||||
|
| 开通申请 | 待处理在前;标记「已到账」→「开通」(选套餐和周期,默认专业版 1 年),或「退回」写原因;开通后填发票号 | `GET /activation-requests`、`POST .../{id}/activate`、`/reject`、`/invoice` |
|
||||||
|
| 额度调整 | 在企业详情里:单位、±数量、原因(必填) | `POST /tenants/{id}/quota-adjustments` |
|
||||||
|
| 反馈 | 误报 / 漏报列表:类型、用户写的理由、问题标题和级别、规则编号、提示词版本、出处页码,**不显示招标 / 投标文件原文**(M6-7) | `GET /feedback` |
|
||||||
|
| 失败任务 | 解析、检查、报告失败的:错误类别、错误码、阶段、耗时、模型调用次数,不含文档内容 | `GET /failed-jobs` |
|
||||||
|
| 审计 | 按企业 / 对象 / 时间查审计事件(项目删除后只剩类型和时间) | `GET /audit-events` |
|
||||||
|
|
||||||
|
## 3. 步骤
|
||||||
|
|
||||||
|
1. 用户端后端:流水、提醒、开通申请、到期任务、删除项目、注销、通知已读,各配集成测试(额度对账:任何失败任务都没有净扣点;删除后对象不可解密)。
|
||||||
|
2. 用户端前端:额度与套餐页、账号页、通知入口、删除项目。
|
||||||
|
3. 运营后台后端:`ops` 进程、`bd_ops` 角色(部署脚本)、登录和 TOTP、上表接口、审计。
|
||||||
|
4. 运营后台前端:`apps/admin` 六个页面。
|
||||||
|
5. 浏览器走查:注册 → 用完免费额度 → 申请开通 → 运营开通 → 流水核对 → 到期(改时钟)→ 删除项目 → 注销。
|
||||||
|
|
||||||
|
## 4. 待确认(建议见每条)
|
||||||
|
|
||||||
|
| # | 问题 | 建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| M6-1 | 删除项目要不要「30 天回收站」(原型这么写) | **不要,立即彻底删除**:按已确认的 G10、DM4 销毁数据密钥;回收站要软删除,和「彻底删除」冲突(同 QL6)。确认框要求输入项目名称,写明不能恢复。原型文案改掉 |
|
||||||
|
| M6-2 | 运营接口放在哪 | **单独的第三种进程 `bid-doctor ops`**(同一镜像、单独端口、`bd_ops` 角色);用户端 api 进程里没有运营接口,绕过行级安全策略的代码不会被公网请求触到 |
|
||||||
|
| M6-3 | 运营后台前端选型 | **Vue 3 + 复用 tokens.css / 组件样式,不引入带样式框架**:页面只有六个、以表格和表单为主;和用户端同一套组件,维护成本低。另一种做法是引入 Element Plus 之类加快开发,但多一套视觉和依赖 |
|
||||||
|
| M6-4 | 运营登录 | **用户名 + 密码 + TOTP + IP 白名单**,账号命令行创建(架构 §3.11 已写独立登录、白名单、二次验证) |
|
||||||
|
| M6-5 | 套餐到期后怎么办 | **不自动降为免费版**;项目只读(能看、能下载已导出的报告、能删除;不能新建项目、解析、检查、改清单);额度清零;提前 30 天、7 天、当天提醒。续费到账后恢复 |
|
||||||
|
| M6-6 | 开通申请页写不写价格、收款信息从哪来 | **页面不写价格**(D2 额度数字和定价都还没定),写「运营会联系你确认金额」;收款单位 / 开户行 / 账号从配置读,没配时显示「(待定)」,生产环境没配拒绝启动 |
|
||||||
|
| M6-7 | 运营看反馈时能不能看原文 | **不看原文**:只看标题、理由、规则编号、提示词版本、页码(红线 10:数据敏感、按租户隔离)。要复盘原文时请用户导出报告发过来,或以后做「用户授权查看」 |
|
||||||
|
| M6-8 | 注销账号的确认方式 | **输入企业名称 + 短信验证码**;有没到期的专业版时写明剩余额度作废、不退款 |
|
||||||
|
|
||||||
|
## 5. 落地记录(2026-11-05)
|
||||||
|
|
||||||
|
M6-1–M6-8 全部按建议确认后实现。和上文不一样、或计划里没写到的:
|
||||||
|
|
||||||
|
- **迁移 0010**:订阅加「排队中」(scheduled),续费到账时当前一期还没到期就排队,到点由定时任务生效发放;开通申请加申请单号 `request_no`(KT- 加 6 位);通知加「已到期」「申请被退回」;`billing_due(now)` 是 bd_auth 所有的 SECURITY DEFINER 函数,定时任务(worker 每小时 `billing_tick`)用它跨企业找到期、该生效、该提醒的订阅,只拿编号,处理时回到各企业自己的租户事务。到期时还有没了结的预扣就等下一次再清零,避免退回的 1 次漏在余额里。
|
||||||
|
- **迁移 0011**:`bd_ops` 的权限(跨企业只读、运营账号和会话表可写、审计可写),`bd_app` 收回运营账号表的权限。角色本身由部署脚本建(`init-roles.sh`,CI 同一个脚本)。
|
||||||
|
- **只读的落点**:到期后写操作在鉴权依赖里统一拦(`identity/deps.py` 的 `READ_ONLY_ALLOWED`),退出、续费申请、删除项目、注销、通知已读、反馈、管理登录设备照常放行;不用逐个接口去改。
|
||||||
|
- **额度单位**:只发放现在用得上的「解析」;专业版套餐里的 `generate`(M4 标书生成)等功能上线再发,免得顶栏出现用不了的额度。
|
||||||
|
- **删除项目**:正在解析、检查、生成报告时返回 409 `job_already_running`,先取消(预扣退回)再删;`data_key` 销毁以后不再建新密钥,还在跑的任务写不回文件。审计的 `before` / `after` 改为 Python 的 None 存成 SQL NULL(以前是 JSON 的 null)。
|
||||||
|
- **注销账号**:验证码改为登录后 `POST /me/sms-codes` 发到自己的号码(前端不知道完整号码);同步部分完成后手机号就能重新注册,删项目、资质库、抹审计内容、销毁密钥、清对象存储由后台任务 `close_tenant` 做,可以重复执行。
|
||||||
|
- **uuid7 单调**:同一进程内严格递增(RFC 9562 方法 2),额度流水按编号排序算逐条余额。
|
||||||
|
- **顺手修的**:取消解析、取消检查的接口在刷新以前转 API 会触发懒加载报错(MissingGreenlet),取消按钮会 500。
|
||||||
|
- **运营后台**:接口说明见 `ops-api.md`;前端 `apps/admin`(`pnpm dev:admin`,:5174)六个页面:登录、开通申请、企业(含调额度)、误报 / 漏报、失败任务、审计。
|
||||||
|
|
||||||
|
测试:后端 156 个(新增 `test_billing_flow.py` 4 个、`test_ops_console.py` 3 个,覆盖流水对账、提醒只发一次、开通 / 续费 / 到期只读、删除后不可解密、注销后可重新注册、运营登录和 IP 白名单、`bd_app` 读不到运营表、`bd_ops` 改不了业务表)。浏览器走查:注册 → 申请开通 → 运营标记到账、开通、登记发票 → 流水核对 → 调额度 → 到期只读 → 删除项目 → 注销账号。
|
||||||
|
|
||||||
|
没做的:运营后台没有「撤回申请」「延长订阅」这类操作(需要时用退回 + 重新申请、调额度代替);收款信息和发票是线下流程,系统只登记发票号。
|
||||||
|
|
||||||
|
**运营后台第二版(2026-11-05,决策 M6-9、M6-10)**:菜单改到左侧(墨色渐变底,标准横版 logo 反白,非生产环境挂赭色环境标签),分组可以展开收起、每项带图标和待办数(`GET /summary`),底部是当前运营人员(姓名、角色)和退出;登录页加 logo。运营人员的角色只留结构:迁移 0012 加 `ops_admin.role`,权限点和「角色 → 权限点」对应表、每个接口的检查点、按权限点过滤的菜单都已就位,现在只有管理员。见 `ops-api.md`。
|
||||||
@@ -203,12 +203,40 @@ paths:
|
|||||||
'204': { description: 已吊销 }
|
'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'
|
||||||
|
|||||||
65
docs/architecture/ops-api.md
Normal file
65
docs/architecture/ops-api.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 运营后台接口(/ops/v1)
|
||||||
|
|
||||||
|
运营后台的接口不在用户端契约 `openapi.yaml` 里:它们只挂在 `bid-doctor ops` 进程上(决策 M6-2),前端 `apps/admin` 是唯一的调用方,类型在 `apps/admin/src/api.ts` 里手写。实现见 `server/src/bid_doctor/ops/`(`console_api.py` 路由、`console.py` 查询、`auth.py` 登录)。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
| 方面 | 做法 |
|
||||||
|
|---|---|
|
||||||
|
| 进程 | 和 api、worker 同一镜像的第三种进程,单独端口(开发 8019);部署时只对内网开放 |
|
||||||
|
| 来源地址 | `BD_OPS_ALLOWED_IPS`(CIDR 列表)以外的请求一律 403 `ops_ip_denied`;生产环境不配或放开全部地址拒绝启动 |
|
||||||
|
| 登录 | 用户名 + 密码(scrypt)+ TOTP 6 位动态码(身份验证器 App);同一用户名 15 分钟最多试 5 次;账号只能用 `bid-doctor ops-admin create` 建 |
|
||||||
|
| 会话 | cookie `bd_ops_session`(HttpOnly、SameSite=Strict,8 小时)+ 写操作带 `X-CSRF-Token`(等于 cookie `bd_ops_csrf`) |
|
||||||
|
| 数据库 | 跨企业查询用 `bd_ops`(绕过行级安全策略,业务表只读,运营账号和会话表可写,审计可写);对某个企业的修改走 `bd_app` + 那个企业的租户事务。`bd_app` 读不到运营账号表 |
|
||||||
|
| 留痕 | 每个操作都写审计(`actor_kind = ops`,记运营账号),查看企业详情也记一条 `ops.tenant.view` |
|
||||||
|
| 内容 | 不显示项目名称、招标 / 投标原文、审计的修改前后(决策 M6-7、红线 10) |
|
||||||
|
|
||||||
|
## 角色和权限点(只留结构,决策 M6-10)
|
||||||
|
|
||||||
|
以后会有管理员、运营、销售、运维等不同权限的运营人员。现在只建结构:
|
||||||
|
|
||||||
|
- `ops_admin.role`(迁移 0012,不加 CHECK);
|
||||||
|
- 权限点 `OpsPermission`(`ops/auth.py`):`tenant.view`、`quota.adjust`、`activation.manage`、`invoice.manage`、`feedback.view`、`jobs.view`、`audit.view`;
|
||||||
|
- 「角色 → 权限点」对应表 `ROLE_PERMISSIONS`:现在只有 `admin`(全部权限)。表里有、对应表里没有的角色什么权限都没有;
|
||||||
|
- 每个接口开头都调 `auth.require(op, 权限点)`,没有权限返回 403 `forbidden`;`/me` 返回角色和权限点,前端菜单按权限点过滤(`apps/admin/src/nav.ts`),一组都看不到时整组不显示。
|
||||||
|
|
||||||
|
加角色时只改 `ROLE_PERMISSIONS`、`ROLE_LABEL` 和 `ops-admin create --role` 的说明;要不要有「运营账号管理」页面、谁能建账号,到时再定。
|
||||||
|
|
||||||
|
## 菜单(决策 M6-9)
|
||||||
|
|
||||||
|
左侧菜单由 `apps/admin/src/nav.ts` 一处配置:分组(客户、质量、安全)→ 菜单项(图标、路由、要的权限点、待办数)。分组可以展开收起,记在本机;跳到收起分组里的页面时自动展开。待办数来自 `GET /summary`(待处理的开通申请、近 7 天失败任务、近 7 天反馈),做完操作和每分钟刷新一次。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/auth/login` | `{username, password, totp}`;失败 401 `ops_login_failed` |
|
||||||
|
| POST | `/auth/logout` | |
|
||||||
|
| GET | `/me` | 当前运营账号:角色、权限点、运行环境(非生产环境时菜单顶部提示) |
|
||||||
|
| GET | `/summary` | 菜单上的待办数;没有权限的那一项给 0 |
|
||||||
|
| GET | `/tenants?q=` | 企业名称(模糊)、手机号(精确,按 HMAC 查)、申请单号;不带条件列最近注册的 50 个 |
|
||||||
|
| GET | `/tenants/{id}` | 成员(手机号脱敏)、套餐历史、最近 30 条额度流水(不带项目名称)、任务按状态计数 |
|
||||||
|
| POST | `/tenants/{id}/quota-adjustments` | `{unit: parse, amount, reason}`;原因必填,不能扣成负数(G6) |
|
||||||
|
| GET | `/activation-requests?status=` | 待处理(等待转账、已到账)在前 |
|
||||||
|
| POST | `/activation-requests/{id}/paid` | 标记到账(只限等待转账) |
|
||||||
|
| POST | `/activation-requests/{id}/activate` | `{months = 12}`;免费版 / 已过期马上开始新一期,专业版没到期时新一期排队(续费) |
|
||||||
|
| POST | `/activation-requests/{id}/reject` | `{note}`,用户在额度页看得到原因 |
|
||||||
|
| POST | `/activation-requests/{id}/invoice` | `{invoice_no}`,开通以后才能登记 |
|
||||||
|
| GET | `/feedback?kind=` | 误报 / 漏报:标题、级别、理由、规则编号、规则集和提示词版本、页码 |
|
||||||
|
| GET | `/failed-jobs` | 解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数 |
|
||||||
|
| GET | `/audit-events?tenant_id=&action=` | 动作、对象、操作人、时间;`action` 按前缀筛选 |
|
||||||
|
|
||||||
|
## 本地跑起来
|
||||||
|
|
||||||
|
```
|
||||||
|
cd server && ../.venv/bin/bid-doctor ops # :8019
|
||||||
|
../.venv/bin/bid-doctor ops-admin create --username dev --display-name 开发运营 # 打印 otpauth:// 地址,用身份验证器 App 添加
|
||||||
|
pnpm dev:admin # :5174,/ops 代理到 :8019
|
||||||
|
```
|
||||||
|
|
||||||
|
已有的数据卷是在加 `bd_ops` 之前初始化的,要手工建一次角色(新数据卷由 `deploy/compose/postgres/init-roles.sh` 建):
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec -e PGPASSWORD=dev-owner bid-doctor-postgres-1 psql -U bd_owner -d bid_doctor \
|
||||||
|
-c "CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD 'dev-ops'" -c "GRANT CONNECT ON DATABASE bid_doctor TO bd_ops"
|
||||||
|
```
|
||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
packages/api-client/src/schema.d.ts
vendored
111
packages/api-client/src/schema.d.ts
vendored
@@ -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
31
pnpm-lock.yaml
generated
@@ -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':
|
||||||
|
|||||||
66
server/migrations/versions/0010_subscription_lifecycle.py
Normal file
66
server/migrations/versions/0010_subscription_lifecycle.py
Normal 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'));
|
||||||
|
""")
|
||||||
37
server/migrations/versions/0011_ops_role_grants.py
Normal file
37
server/migrations/versions/0011_ops_role_grants.py
Normal 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;
|
||||||
|
""")
|
||||||
22
server/migrations/versions/0012_ops_admin_role.py
Normal file
22
server/migrations/versions/0012_ops_admin_role.py
Normal 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")
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"WITH e AS (SELECT * FROM quota_entry"
|
||||||
|
" WHERE created_at >= :start AND (CAST(:end AS timestamptz) IS NULL OR created_at < :end))"
|
||||||
" SELECT a.unit, a.balance,"
|
" SELECT a.unit, a.balance,"
|
||||||
" coalesce(sum(e.amount) FILTER (WHERE e.kind = 'grant' AND e.subscription_id = :s), 0),"
|
" (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,
|
||||||
|
)
|
||||||
|
|||||||
25
server/src/bid_doctor/billing/tasks.py
Normal file
25
server/src/bid_doctor/billing/tasks.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 秒
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, "这类文件不需要单独解析"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
111
server/src/bid_doctor/identity/closure.py
Normal file
111
server/src/bid_doctor/identity/closure.py
Normal 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))
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
52
server/src/bid_doctor/ops/app.py
Normal file
52
server/src/bid_doctor/ops/app.py
Normal 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
|
||||||
180
server/src/bid_doctor/ops/auth.py
Normal file
180
server/src/bid_doctor/ops/auth.py
Normal 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)
|
||||||
336
server/src/bid_doctor/ops/console.py
Normal file
336
server/src/bid_doctor/ops/console.py
Normal 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]}
|
||||||
222
server/src/bid_doctor/ops/console_api.py
Normal file
222
server/src/bid_doctor/ops/console_api.py
Normal 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)}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
205
server/tests/test_billing_flow.py
Normal file
205
server/tests/test_billing_flow.py
Normal 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
|
||||||
181
server/tests/test_ops_console.py
Normal file
181
server/tests/test_ops_console.py
Normal 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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user