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:
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: "报告" };
|
||||
Reference in New Issue
Block a user