feat: bootstrap local platform development
This commit is contained in:
24
README.md
24
README.md
@@ -46,7 +46,26 @@ docs # ADR、架构说明和迁移路线
|
|||||||
GET /platform-admin/
|
GET /platform-admin/
|
||||||
```
|
```
|
||||||
|
|
||||||
它是功能原型壳,不是正式视觉规范。默认 mock 模式;联调时通过 `runtime-config.js` 注入同源 `/api`、平台 access token provider 和 API 模式。
|
它是功能原型壳,不是正式视觉规范。当前默认连接同源真实 API,并提供平台登录、首次改密和 TOTP MFA 流程;token 只保存在当前浏览器标签的 `sessionStorage`。真实模式不会回退显示 Mock 数据,目前开放概览、租户、员工和审计这组已经落地后端契约的页面,账务、公共题库等页面随对应 API 实现逐步开放。
|
||||||
|
|
||||||
|
本地首次启动先创建 `tiku` 数据库并执行迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb -h 127.0.0.1 -U "$(whoami)" tiku
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator
|
||||||
|
```
|
||||||
|
|
||||||
|
Development 首次迁移会通过 EF Core 官方推荐的 `UseSeeding` / `UseAsyncSeeding` 初始化平台超级管理员;Migration Lock 保证并发安全,后续重复执行迁移会幂等跳过,不会重置密码或重复创建:
|
||||||
|
|
||||||
|
```text
|
||||||
|
登录地址:http://localhost:5090/platform-admin/
|
||||||
|
初始账号:admin@tiku.local
|
||||||
|
初始密码:由 Tiku.DbMigrator 安全随机生成,仅在首次初始化的终端输出一次
|
||||||
|
```
|
||||||
|
|
||||||
|
首次登录必须立即修改初始密码并绑定 TOTP MFA。如果丢失首次输出的临时密码,应删除尚无业务数据的本地开发库后重新初始化,不要把密码补写到源码、`appsettings*.json` 或 README。Production 不会自动创建默认管理员,必须使用下文的显式安全引导命令。
|
||||||
|
|
||||||
|
开发环境只隐藏 EF Core 成功 SQL 日志,ORM 警告与错误仍会输出。
|
||||||
|
|
||||||
当前不把后端改成 MVC/Razor,也不为这个静态 demo 单独维护 Node 服务。后续平台端产品化时,建议迁为独立 React/Vite/Next 工程,.NET 继续提供 API。
|
当前不把后端改成 MVC/Razor,也不为这个静态 demo 单独维护 Node 服务。后续平台端产品化时,建议迁为独立 React/Vite/Next 工程,.NET 继续提供 API。
|
||||||
|
|
||||||
@@ -85,6 +104,7 @@ PostgreSQL guard 负责 EF 无法表达的跨表租户不变量:
|
|||||||
|
|
||||||
## 文档入口
|
## 文档入口
|
||||||
|
|
||||||
|
- [本地开发快速开始](docs/quickstart.md)
|
||||||
- [当前认证、授权与 Host 安全策略](docs/architecture/authentication-authorization-security.md)
|
- [当前认证、授权与 Host 安全策略](docs/architecture/authentication-authorization-security.md)
|
||||||
- [认证与授权待补强清单](docs/architecture/authentication-authorization-hardening-plan.md)
|
- [认证与授权待补强清单](docs/architecture/authentication-authorization-hardening-plan.md)
|
||||||
- [迁移路线与剩余范围](docs/migration-roadmap.md)
|
- [迁移路线与剩余范围](docs/migration-roadmap.md)
|
||||||
@@ -117,7 +137,7 @@ dotnet ef migrations has-pending-model-changes \
|
|||||||
--startup-project Tiku.DbMigrator
|
--startup-project Tiku.DbMigrator
|
||||||
```
|
```
|
||||||
|
|
||||||
首次部署可在迁移完成后创建平台超级管理员:
|
Production 首次部署可在迁移完成后显式创建平台超级管理员:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='admin@example.com'
|
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='admin@example.com'
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public static class ApplicationBuilderExtensions
|
|||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseCors(CorsOptions.PolicyName);
|
app.UseCors(CorsOptions.PolicyName);
|
||||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"Override": {
|
"Override": {
|
||||||
"Microsoft": "Warning",
|
"Microsoft": "Warning",
|
||||||
"Microsoft.AspNetCore": "Information",
|
"Microsoft.AspNetCore": "Information",
|
||||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
"System.Net.Http.HttpClient": "Warning"
|
"System.Net.Http.HttpClient": "Warning"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1351
Tiku.Api/wwwroot/platform-admin/app.js
Normal file
1351
Tiku.Api/wwwroot/platform-admin/app.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Tiku.Api/wwwroot/platform-admin/assets/logo.png
Normal file
BIN
Tiku.Api/wwwroot/platform-admin/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
35
Tiku.Api/wwwroot/platform-admin/index.html
Normal file
35
Tiku.Api/wwwroot/platform-admin/index.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#111827" />
|
||||||
|
<title>恭学题库 SaaS · 平台管理</title>
|
||||||
|
<link rel="icon" href="./assets/logo.png" />
|
||||||
|
<link rel="stylesheet" href="./styles.css?v=20260728-api2" />
|
||||||
|
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer onerror="document.documentElement.classList.add('icons-unavailable')"></script>
|
||||||
|
<script src="./runtime-config.js?v=20260728-api2" defer></script>
|
||||||
|
<script src="./platform-auth.js?v=20260728-api2" defer></script>
|
||||||
|
<script src="./spec-contract.js?v=20260728-api2" defer></script>
|
||||||
|
<script src="./platform-api.js?v=20260728-api2" defer></script>
|
||||||
|
<script src="./app.js?v=20260728-api2" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="platformAuthGate" class="platform-auth-gate" hidden></div>
|
||||||
|
<div class="platform-app">
|
||||||
|
<aside class="platform-sidebar">
|
||||||
|
<button class="platform-brand" data-nav="overview"><img src="./assets/logo.png" alt="" /><span><strong>恭学 SaaS</strong><small>平台管理控制台</small></span></button>
|
||||||
|
<div class="environment"><i></i><span>生产环境</span><b>CN</b></div>
|
||||||
|
<nav id="sideNav"></nav>
|
||||||
|
<div class="platform-foot"><button data-nav="staff"><i data-lucide="shield-check"></i><span>平台权限</span></button><div><span>陈</span><p><strong>陈浩</strong><small>超级管理员</small></p><button data-action="logout" aria-label="退出平台端"><i data-lucide="log-out"></i></button></div></div>
|
||||||
|
</aside>
|
||||||
|
<section class="platform-workspace">
|
||||||
|
<header class="platform-topbar"><button class="icon-button mobile-menu" id="menuButton"><i data-lucide="menu"></i></button><div class="page-context"><small>PLATFORM / PROD</small><strong id="pageTitle">平台经营工作台</strong></div><label class="platform-search"><i data-lucide="search"></i><input id="globalSearch" placeholder="搜索租户、账单、告警或审计事件" /><kbd>⌘ K</kbd></label><div class="prototype-controls"><label title="切换角色以验证菜单和动作权限"><span>角色</span><select id="roleSelect"></select></label><label title="演练加载、空、错误和 403 页面状态"><span>页面状态</span><select id="scenarioSelect"></select></label><label title="控制下一次 Mock 写请求结果"><span>写请求</span><select id="responseSelect"></select></label></div><div class="top-status"><span><i></i>所有系统正常</span><button class="icon-button" data-action="open-task-center" aria-label="任务中心" title="任务中心"><i data-lucide="list-checks"></i><b id="taskCount">0</b></button><button class="icon-button" data-nav="alerts" aria-label="告警与通知" title="告警与通知"><i data-lucide="bell-ring"></i><b id="alertCount">0</b></button></div></header>
|
||||||
|
<main id="mainView"></main>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-backdrop" id="sidebarBackdrop" aria-label="关闭导航菜单"></button>
|
||||||
|
<div id="overlayRoot"></div>
|
||||||
|
<div class="toast"><i data-lucide="circle-check"></i><span></span></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
886
Tiku.Api/wwwroot/platform-admin/platform-api.js
Normal file
886
Tiku.Api/wwwroot/platform-admin/platform-api.js
Normal file
@@ -0,0 +1,886 @@
|
|||||||
|
(() => {
|
||||||
|
const defaults = {
|
||||||
|
mode: 'mock',
|
||||||
|
apiBaseUrl: '',
|
||||||
|
fallbackToMock: true,
|
||||||
|
timeoutMs: 10000,
|
||||||
|
getAccessToken: null,
|
||||||
|
};
|
||||||
|
const runtime = { ...defaults, ...(window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {}) };
|
||||||
|
const operationCatalog = Object.fromEntries(
|
||||||
|
(window.GONGXUE_PLATFORM_SPEC?.operations || []).map(operation => [operation.operationId, operation]),
|
||||||
|
);
|
||||||
|
const trace = [];
|
||||||
|
|
||||||
|
class PlatformApiError extends Error {
|
||||||
|
constructor(message, options = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PlatformApiError';
|
||||||
|
this.status = Number(options.status || 0);
|
||||||
|
this.code = options.code || 'PLATFORM_API_ERROR';
|
||||||
|
this.requestId = options.requestId || '';
|
||||||
|
this.operationId = options.operationId || '';
|
||||||
|
this.details = options.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
return String(value || '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return runtime.mode === 'api' || (runtime.mode === 'auto' && Boolean(normalizeBaseUrl(runtime.apiBaseUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicConfig() {
|
||||||
|
return {
|
||||||
|
mode: runtime.mode,
|
||||||
|
apiBaseUrl: normalizeBaseUrl(runtime.apiBaseUrl),
|
||||||
|
fallbackToMock: runtime.fallbackToMock !== false,
|
||||||
|
timeoutMs: Number(runtime.timeoutMs) || defaults.timeoutMs,
|
||||||
|
configured: isEnabled(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accessToken() {
|
||||||
|
const provider = typeof runtime.getAccessToken === 'function'
|
||||||
|
? runtime.getAccessToken
|
||||||
|
: typeof window.GONGXUE_PLATFORM_ACCESS_TOKEN_PROVIDER === 'function'
|
||||||
|
? window.GONGXUE_PLATFORM_ACCESS_TOKEN_PROVIDER
|
||||||
|
: null;
|
||||||
|
const token = provider ? await provider() : '';
|
||||||
|
return String(token || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path, query = {}) {
|
||||||
|
const baseUrl = normalizeBaseUrl(runtime.apiBaseUrl);
|
||||||
|
const url = new URL(`${baseUrl}${path.startsWith('/') ? path : `/${path}`}`, window.location.href);
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
url.searchParams.set(key, Array.isArray(value) ? value.join(',') : String(value));
|
||||||
|
});
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseRequestId(payload, response) {
|
||||||
|
return String(
|
||||||
|
payload?.meta?.requestId
|
||||||
|
|| payload?.requestId
|
||||||
|
|| response?.headers?.get?.('x-request-id')
|
||||||
|
|| '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(operationId, options = {}) {
|
||||||
|
const operation = operationCatalog[operationId];
|
||||||
|
if (!operation) {
|
||||||
|
throw new PlatformApiError(`未知平台接口:${operationId}`, {
|
||||||
|
code: 'PLATFORM_OPERATION_UNKNOWN',
|
||||||
|
operationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isEnabled()) {
|
||||||
|
throw new PlatformApiError('平台 API 未启用', {
|
||||||
|
code: 'PLATFORM_API_DISABLED',
|
||||||
|
operationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const token = await accessToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new PlatformApiError('缺少平台管理员 access token', {
|
||||||
|
status: 401,
|
||||||
|
code: 'PLATFORM_ACCESS_TOKEN_MISSING',
|
||||||
|
operationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const extraHeaders = Object.fromEntries(Object.entries(options.headers || {}).map(([key, value]) => [key, String(value)]));
|
||||||
|
const forbiddenHeader = Object.keys(extraHeaders).find((key) => {
|
||||||
|
const normalized = key.toLowerCase();
|
||||||
|
return ['authorization', 'x-user-id', 'x-platform-admin-key'].includes(normalized)
|
||||||
|
|| normalized.startsWith('x-tenant-');
|
||||||
|
});
|
||||||
|
if (forbiddenHeader) {
|
||||||
|
throw new PlatformApiError(`禁止页面覆盖身份头:${forbiddenHeader}`, {
|
||||||
|
code: 'PLATFORM_IDENTITY_HEADER_FORBIDDEN',
|
||||||
|
operationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), Number(runtime.timeoutMs) || defaults.timeoutMs);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
if (options.signal) {
|
||||||
|
if (options.signal.aborted) controller.abort();
|
||||||
|
else options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildUrl(operation.path, options.query), {
|
||||||
|
method: operation.method,
|
||||||
|
headers: {
|
||||||
|
...extraHeaders,
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(operation.method === 'GET' ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
},
|
||||||
|
body: operation.method === 'GET' || options.body === undefined
|
||||||
|
? undefined
|
||||||
|
: JSON.stringify(options.body),
|
||||||
|
credentials: 'omit',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = {};
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = { error: text.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requestId = responseRequestId(payload, response);
|
||||||
|
trace.push({
|
||||||
|
operationId,
|
||||||
|
method: operation.method,
|
||||||
|
path: operation.path,
|
||||||
|
status: response.status,
|
||||||
|
requestId,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (trace.length > 120) trace.splice(0, trace.length - 120);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new PlatformApiError(
|
||||||
|
String(payload.message || payload.error || `请求失败(HTTP ${response.status})`),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
code: String(payload.code || 'PLATFORM_API_REQUEST_FAILED'),
|
||||||
|
requestId,
|
||||||
|
operationId,
|
||||||
|
details: payload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PlatformApiError) throw error;
|
||||||
|
const aborted = error?.name === 'AbortError';
|
||||||
|
throw new PlatformApiError(aborted ? '平台接口请求超时' : (error?.message || '平台接口请求失败'), {
|
||||||
|
code: aborted ? 'PLATFORM_API_TIMEOUT' : 'PLATFORM_API_NETWORK_ERROR',
|
||||||
|
operationId,
|
||||||
|
details: error,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictionaries = {
|
||||||
|
tenantStatusToApi: { 待配置: 'draft', 试用: 'active', 正常: 'active', 暂停: 'suspended', 已归档: 'archived' },
|
||||||
|
tenantStatusFromApi: { draft: '待配置', active: '正常', suspended: '暂停', archived: '已归档' },
|
||||||
|
billingStatusToApi: { 试用: 'trial', 正常: 'active', 待付款: 'active', 逾期: 'past_due', 暂停: 'suspended', 已取消: 'cancelled' },
|
||||||
|
billingStatusFromApi: { trial: '试用', active: '正常', past_due: '逾期', suspended: '暂停', cancelled: '已取消' },
|
||||||
|
subscriptionStatusToApi: { 试用中: 'trial', 活跃: 'active', 暂停: 'suspended', 已取消: 'cancelled', 已到期: 'expired' },
|
||||||
|
subscriptionStatusFromApi: { trial: '试用中', active: '活跃', suspended: '暂停', cancelled: '已取消', expired: '已到期' },
|
||||||
|
invoiceStatusFromApi: { draft: '草稿', issued: '待付款', paid: '已支付', void: '已作废', overdue: '已逾期' },
|
||||||
|
invoiceTypeToApi: { 订阅服务费: 'subscription', 用量超额费: 'usage', 人工调整: 'adjustment', 首期服务费: 'manual' },
|
||||||
|
invoiceTypeFromApi: { subscription: '订阅服务费', usage: '用量超额费', usage_overage: '用量超额费', manual: '人工账单', adjustment: '人工调整' },
|
||||||
|
severityToApi: { 低: 'low', 中: 'medium', 高: 'high', 严重: 'critical' },
|
||||||
|
severityFromApi: { low: '低', medium: '中', high: '高', critical: '严重' },
|
||||||
|
grantScopeToApi: { 全部活跃租户: 'all_active_tenants', 指定套餐: 'plans', 指定租户: 'tenants', '套餐 + 租户': 'mixed' },
|
||||||
|
grantScopeFromApi: { all_active_tenants: '全部活跃租户', plans: '指定套餐', tenants: '指定租户', mixed: '套餐 + 租户' },
|
||||||
|
grantStatusFromApi: { active: '启用', disabled: '已禁用', expired: '已过期' },
|
||||||
|
providerToApi: { Webhook: 'generic', SMTP: 'generic', 邮件: 'generic', 短信: 'generic', 钉钉: 'dingtalk', 飞书: 'feishu', 企微: 'wecom' },
|
||||||
|
providerFromApi: { generic: 'Webhook', dingtalk: '钉钉', feishu: '飞书', wecom: '企微' },
|
||||||
|
eventStatusFromApi: { pending: '待处理', processing: '发送中', sent: '成功', retrying: '重试中', failed: '失败', discarded: '已丢弃' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapValue(map, value, fallback = value) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(map, value) ? map[value] : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centsToYuan(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number / 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yuanToCents(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? Math.round(number * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateOnly(value) {
|
||||||
|
return value ? String(value).slice(0, 10) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthOnly(value) {
|
||||||
|
return value ? String(value).slice(0, 7) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateTime(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return String(value);
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(parsed).replaceAll('/', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoStartOfDay(value) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
return `${String(value).slice(0, 10)}T00:00:00+08:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
|
||||||
|
return String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodBounds(period) {
|
||||||
|
const match = String(period || '').match(/^(\d{4})-(\d{2})$/);
|
||||||
|
if (!match) return { periodStart: '', periodEnd: '' };
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
return {
|
||||||
|
periodStart: `${match[1]}-${match[2]}-01`,
|
||||||
|
periodEnd: `${match[1]}-${match[2]}-${String(lastDay).padStart(2, '0')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoicePeriodBounds(label) {
|
||||||
|
const match = String(label || '').match(/^(\d{4})年(\d{1,2})月$/);
|
||||||
|
return match ? periodBounds(`${match[1]}-${String(match[2]).padStart(2, '0')}`) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyBuilders = {
|
||||||
|
PlatformAdminTenantsController_create(input) {
|
||||||
|
return {
|
||||||
|
slug: input.slug,
|
||||||
|
name: input.name,
|
||||||
|
legalName: input.legalName || undefined,
|
||||||
|
brandName: input.brandName || undefined,
|
||||||
|
shortName: input.shortName || undefined,
|
||||||
|
primaryHost: input.primaryHost || undefined,
|
||||||
|
planCode: input.planCode || undefined,
|
||||||
|
status: mapValue(dictionaries.tenantStatusToApi, input.status, input.status),
|
||||||
|
billingStatus: mapValue(dictionaries.billingStatusToApi, input.billingStatus, input.billingStatus),
|
||||||
|
amountCents: yuanToCents(input.amount),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminTenantsController_status(input) {
|
||||||
|
return {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
status: mapValue(dictionaries.tenantStatusToApi, input.status, input.status),
|
||||||
|
billingStatus: mapValue(dictionaries.billingStatusToApi, input.billingStatus, input.billingStatus),
|
||||||
|
reason: input.reason,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminTenantsController_billingProfile(input) {
|
||||||
|
return {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
billingName: input.billingName || undefined,
|
||||||
|
taxId: input.taxId || undefined,
|
||||||
|
contactName: input.contactName || undefined,
|
||||||
|
contactPhone: input.phone || input.contactPhone || undefined,
|
||||||
|
contactEmail: input.email || input.contactEmail || undefined,
|
||||||
|
billingAddress: input.billingAddress || undefined,
|
||||||
|
invoiceTitle: input.invoiceTitle || undefined,
|
||||||
|
invoiceType: ({ 增值税专用发票: 'special_vat', 增值税普通发票: 'normal_vat', 不开票: 'none' })[input.type || input.invoiceType] || input.invoiceType || 'none',
|
||||||
|
bankName: input.bankName || undefined,
|
||||||
|
bankAccountMasked: input.bankAccountMasked || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_createSubscription(input) {
|
||||||
|
return {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
planCode: input.planCode,
|
||||||
|
status: mapValue(dictionaries.subscriptionStatusToApi, input.status, input.status || 'active'),
|
||||||
|
billingCycle: ({ 年度: 'yearly', 月度: 'monthly', 试用: 'trial' })[input.cycle] || input.billingCycle,
|
||||||
|
amountCents: yuanToCents(input.amount),
|
||||||
|
startsAt: isoStartOfDay(input.start || input.startsAt),
|
||||||
|
expiresAt: isoStartOfDay(input.expires || input.expiresAt),
|
||||||
|
metadata: { autoRenew: Boolean(input.autoRenew), source: 'static-platform-console' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_createInvoice(input) {
|
||||||
|
const bounds = invoicePeriodBounds(input.period);
|
||||||
|
return {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
invoiceType: mapValue(dictionaries.invoiceTypeToApi, input.type, input.invoiceType || 'manual'),
|
||||||
|
status: input.status || 'draft',
|
||||||
|
dueDate: input.due || input.dueDate,
|
||||||
|
billingPeriodStart: input.billingPeriodStart || bounds.periodStart,
|
||||||
|
billingPeriodEnd: input.billingPeriodEnd || bounds.periodEnd,
|
||||||
|
items: input.items || [{
|
||||||
|
itemType: mapValue(dictionaries.invoiceTypeToApi, input.type, 'manual'),
|
||||||
|
description: input.note || input.type || '平台手工账单',
|
||||||
|
quantity: 1,
|
||||||
|
unitAmountCents: yuanToCents(input.amount),
|
||||||
|
metadata: { periodLabel: input.period || '' },
|
||||||
|
}],
|
||||||
|
note: input.note || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_fromSubscription(input) {
|
||||||
|
return {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
subscriptionId: input.subscriptionId || undefined,
|
||||||
|
status: input.status || 'issued',
|
||||||
|
dueDate: input.dueDate || undefined,
|
||||||
|
note: input.note || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_fromSubscriptionsBatch(input) {
|
||||||
|
return {
|
||||||
|
tenantIds: input.tenantIds || undefined,
|
||||||
|
subscriptionIds: input.subscriptionIds || undefined,
|
||||||
|
daysAhead: input.daysAhead ?? 45,
|
||||||
|
status: input.status || 'issued',
|
||||||
|
dueDate: input.dueDate || undefined,
|
||||||
|
note: input.note || undefined,
|
||||||
|
dryRun: Boolean(input.dryRun),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_fromUsage(input) {
|
||||||
|
const bounds = input.periodStart && input.periodEnd ? input : { ...input, ...periodBounds(input.period) };
|
||||||
|
return {
|
||||||
|
tenantIds: bounds.tenantIds || undefined,
|
||||||
|
periodStart: bounds.periodStart,
|
||||||
|
periodEnd: bounds.periodEnd,
|
||||||
|
dueDate: bounds.dueDate || undefined,
|
||||||
|
note: bounds.note || undefined,
|
||||||
|
status: bounds.status || 'issued',
|
||||||
|
dryRun: Boolean(bounds.dryRun),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_processOverdue(input) {
|
||||||
|
return { dryRun: Boolean(input.dryRun), channel: input.channel || 'internal', limit: Number(input.limit || 100) };
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_confirmPayment(input) {
|
||||||
|
return {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
invoiceId: input.invoiceId,
|
||||||
|
amountCents: yuanToCents(input.amount),
|
||||||
|
paymentNo: input.paymentNo || undefined,
|
||||||
|
provider: input.provider || 'manual',
|
||||||
|
method: input.method || 'manual',
|
||||||
|
paidAt: input.paidAt ? new Date(input.paidAt).toISOString() : undefined,
|
||||||
|
providerTradeNo: input.tradeNo || input.providerTradeNo || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminBillingController_recordUsage(input) {
|
||||||
|
const bounds = input.periodStart && input.periodEnd ? input : { ...input, ...periodBounds(input.period) };
|
||||||
|
return {
|
||||||
|
tenantId: bounds.tenantId,
|
||||||
|
metricKey: bounds.metricKey,
|
||||||
|
metricValue: Number(bounds.metricValue || 0),
|
||||||
|
periodStart: bounds.periodStart,
|
||||||
|
periodEnd: bounds.periodEnd,
|
||||||
|
metadata: { source: 'platform_console_manual' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminQuestionBanksController_upsertGrant(input) {
|
||||||
|
return {
|
||||||
|
id: input.id || undefined,
|
||||||
|
sourceQuestionBankId: input.bankId || input.sourceQuestionBankId,
|
||||||
|
grantScope: mapValue(dictionaries.grantScopeToApi, input.scope, input.grantScope),
|
||||||
|
allowedPlanCodes: input.allowedPlanCodes || (input.planCode ? [input.planCode] : []),
|
||||||
|
allowedTenantIds: input.allowedTenantIds || (input.tenantId ? [input.tenantId] : []),
|
||||||
|
allowedRegionIds: input.allowedRegionIds || splitList(input.regionIds),
|
||||||
|
allowedSubjectIds: input.allowedSubjectIds || splitList(input.subjectIds),
|
||||||
|
status: input.status === '已禁用' || input.status === 'disabled'
|
||||||
|
? 'disabled'
|
||||||
|
: input.status === '已过期' || input.status === 'expired'
|
||||||
|
? 'expired'
|
||||||
|
: 'active',
|
||||||
|
startsAt: isoStartOfDay(input.startsAt),
|
||||||
|
expiresAt: isoStartOfDay(input.expiresAt),
|
||||||
|
metadata: { localEdit: Boolean(input.localEdit) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminOverviewController_upsertStaff(input) {
|
||||||
|
const permissionList = input.permissions || [];
|
||||||
|
return {
|
||||||
|
id: input.id || undefined,
|
||||||
|
authUserId: input.authUserId,
|
||||||
|
username: input.username,
|
||||||
|
email: input.email || undefined,
|
||||||
|
phone: input.phone || undefined,
|
||||||
|
name: input.name,
|
||||||
|
status: input.status === '已停用' ? 'disabled' : 'active',
|
||||||
|
platformPermissions: Object.fromEntries(permissionList.map(permission => [permission, true])),
|
||||||
|
metadata: { displayRole: input.role || '平台管理员' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminOverviewController_updateStaffStatus(input) {
|
||||||
|
return {
|
||||||
|
staffId: input.staffId,
|
||||||
|
status: input.status === '正常' || input.status === 'active' ? 'active' : 'disabled',
|
||||||
|
reason: input.reason || undefined,
|
||||||
|
revokeSessions: input.revokeSessions !== false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PlatformAdminAuditController_updateAlert(input) {
|
||||||
|
return {
|
||||||
|
alertId: input.alertId,
|
||||||
|
status: input.status,
|
||||||
|
note: input.note || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function notificationBody(input) {
|
||||||
|
const maxAttempts = Number(input.maxAttempts || input.retry || 3);
|
||||||
|
return {
|
||||||
|
id: input.id || undefined,
|
||||||
|
channelCode: input.code || input.channelCode,
|
||||||
|
name: input.name,
|
||||||
|
enabled: input.enabled !== false,
|
||||||
|
provider: mapValue(dictionaries.providerToApi, input.provider, input.provider || 'generic'),
|
||||||
|
webhookUrl: input.endpoint || input.webhookUrl,
|
||||||
|
secret: input.secret || undefined,
|
||||||
|
secretRef: input.secretRef || undefined,
|
||||||
|
minSeverity: mapValue(dictionaries.severityToApi, input.minSeverity, input.minSeverity || 'medium'),
|
||||||
|
statusFilter: splitList(input.statusFilter || 'open,acknowledged'),
|
||||||
|
timeoutSec: Number(input.timeout || input.timeoutSec || 10),
|
||||||
|
maxAttempts,
|
||||||
|
metadata: { ...(input.metadata || {}), maxAttempts },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bodyBuilders.PlatformAdminAuditController_upsertChannel = notificationBody;
|
||||||
|
bodyBuilders.PlatformAdminDunningChannelsController_upsertChannel = input => ({
|
||||||
|
...notificationBody(input),
|
||||||
|
minReminderLevel: Number(input.minReminderLevel || 1),
|
||||||
|
reminderTypes: input.reminderTypes || ['overdue', 'final_notice'],
|
||||||
|
reminderChannels: input.reminderChannels || ['internal'],
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildBody(operationId, input = {}) {
|
||||||
|
const builder = bodyBuilders[operationId];
|
||||||
|
return builder ? builder(input) : input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonSummary(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEndpoint(item) {
|
||||||
|
if (!item?.webhook) return '';
|
||||||
|
const protocol = item.webhook.protocol ? `${item.webhook.protocol}://` : '';
|
||||||
|
return `${protocol}${item.webhook.host || ''}${item.webhook.pathname || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentById(items, id) {
|
||||||
|
return (items || []).find(item => item.id === id) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapters = {
|
||||||
|
plan(item, current = {}) {
|
||||||
|
const quotas = item.includedQuotas && typeof item.includedQuotas === 'object'
|
||||||
|
? Object.entries(item.includedQuotas).map(([key, value]) => `${key}: ${value}`)
|
||||||
|
: current.quotas || [];
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description || '',
|
||||||
|
cycle: ({ yearly: '年度', monthly: '月度' })[item.billingCycle] || item.billingCycle || '-',
|
||||||
|
amount: centsToYuan(item.baseAmountCents),
|
||||||
|
quotas,
|
||||||
|
tenantCount: Number(current.tenantCount || 0),
|
||||||
|
status: item.status,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
tenant(item, current = {}, planMap = {}) {
|
||||||
|
const plan = planMap[item.planCode];
|
||||||
|
return {
|
||||||
|
students: 0,
|
||||||
|
questions: 0,
|
||||||
|
storageGB: 0,
|
||||||
|
trafficTB: 0,
|
||||||
|
balance: 0,
|
||||||
|
host: '-',
|
||||||
|
owner: '待配置',
|
||||||
|
ownerPhone: '-',
|
||||||
|
...current,
|
||||||
|
id: item.id,
|
||||||
|
slug: item.slug,
|
||||||
|
name: item.name,
|
||||||
|
legal: item.legalName || current.legal || item.name,
|
||||||
|
brand: item.brandName || current.brand || item.name,
|
||||||
|
planCode: item.planCode || '',
|
||||||
|
plan: plan?.name || current.plan || item.planCode || '未订阅',
|
||||||
|
status: mapValue(dictionaries.tenantStatusFromApi, item.status, item.status || '-'),
|
||||||
|
billing: mapValue(dictionaries.billingStatusFromApi, item.billingStatus, item.billingStatus || '-'),
|
||||||
|
mode: item.mode === 'saas' ? '正式' : item.mode || current.mode || '-',
|
||||||
|
expires: dateOnly(item.subscriptionExpiresAt),
|
||||||
|
balance: centsToYuan(item.openBalanceCents),
|
||||||
|
createdAt: dateTime(item.createdAt),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
subscription(item) {
|
||||||
|
return {
|
||||||
|
id: item.subscriptionId || item.id,
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
planCode: item.planCode || '',
|
||||||
|
status: mapValue(dictionaries.subscriptionStatusFromApi, item.status, item.status || '-'),
|
||||||
|
cycle: ({ yearly: '年度', monthly: '月度', trial: '试用' })[item.billingCycle] || item.billingCycle || '-',
|
||||||
|
amount: centsToYuan(item.amountCents),
|
||||||
|
start: dateOnly(item.startsAt),
|
||||||
|
expires: dateOnly(item.expiresAt),
|
||||||
|
autoRenew: Boolean(item.metadata?.autoRenew),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
invoice(item) {
|
||||||
|
return {
|
||||||
|
id: item.invoiceNo || item.id,
|
||||||
|
backendId: item.id,
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
type: mapValue(dictionaries.invoiceTypeFromApi, item.invoiceType, item.invoiceType || '-'),
|
||||||
|
period: item.billingPeriodStart ? monthOnly(item.billingPeriodStart) : monthOnly(item.issuedAt || item.dueDate),
|
||||||
|
total: centsToYuan(item.totalCents),
|
||||||
|
paid: centsToYuan(item.paidCents),
|
||||||
|
balance: centsToYuan(item.balanceCents),
|
||||||
|
due: dateOnly(item.dueDate),
|
||||||
|
status: mapValue(dictionaries.invoiceStatusFromApi, item.status, item.status || '-'),
|
||||||
|
note: item.note || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
reminder(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
invoiceId: item.invoiceNo || item.invoiceId,
|
||||||
|
backendInvoiceId: item.invoiceId,
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
level: Number(item.reminderLevel || 1),
|
||||||
|
channel: item.channel || '-',
|
||||||
|
status: mapValue(dictionaries.eventStatusFromApi, item.status, item.status || '-'),
|
||||||
|
date: dateTime(item.reminderDate || item.createdAt),
|
||||||
|
message: item.message || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
bank(item, current = {}) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
catalog: item.regionName || item.tenantName || '公共题库',
|
||||||
|
source: item.sourceScope === 'platform' ? '平台公共题库' : '租户自建',
|
||||||
|
questions: Number(item.questionCount || 0),
|
||||||
|
version: item.metadata?.version || item.metadata?.contentVersion || '-',
|
||||||
|
status: item.status === 'active' ? '稳定版' : item.status || '-',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
grant(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
bankId: item.sourceQuestionBankId,
|
||||||
|
scope: mapValue(dictionaries.grantScopeFromApi, item.grantScope, item.grantScope || '-'),
|
||||||
|
allowedPlanCodes: item.allowedPlanCodes || [],
|
||||||
|
allowedTenantIds: item.allowedTenantIds || [],
|
||||||
|
allowedRegionIds: item.allowedRegionIds || [],
|
||||||
|
allowedSubjectIds: item.allowedSubjectIds || [],
|
||||||
|
status: mapValue(dictionaries.grantStatusFromApi, item.status, item.status || '-'),
|
||||||
|
startsAt: dateOnly(item.startsAt),
|
||||||
|
expiresAt: dateOnly(item.expiresAt),
|
||||||
|
localEdit: Boolean(item.metadata?.localEdit),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sync(item) {
|
||||||
|
const progress = item.syncStatus === 'synced'
|
||||||
|
? 100
|
||||||
|
: Number(item.metadata?.lastSync?.progress || item.lastSyncCounts?.progress || 0);
|
||||||
|
return {
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
bankId: item.sourceQuestionBankId,
|
||||||
|
current: item.metadata?.lastSync?.sourceVersion || '-',
|
||||||
|
target: item.metadata?.lastSync?.targetVersion || '-',
|
||||||
|
progress,
|
||||||
|
conflicts: Number(item.conflictCount || 0),
|
||||||
|
status: ({ pending: '同步中', synced: '已完成', failed: '同步失败' })[item.syncStatus] || item.syncStatus || item.status || '-',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
staff(item) {
|
||||||
|
const permissions = item.roleCodes?.includes('platform_super_admin') ? ['*'] : [];
|
||||||
|
return {
|
||||||
|
id: item.userId || item.id,
|
||||||
|
authUserId: item.userId || item.authUserId || '',
|
||||||
|
username: item.email || '',
|
||||||
|
name: item.name || item.email || '平台员工',
|
||||||
|
email: item.email || '',
|
||||||
|
phone: item.phoneMasked || item.phone || '-',
|
||||||
|
role: permissions.includes('*') ? '超级管理员' : item.rawProfile?.displayRole || '平台管理员',
|
||||||
|
permissions,
|
||||||
|
status: item.status === 'active' ? '正常' : '已停用',
|
||||||
|
lastActive: dateTime(item.updatedAt),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
audit(item) {
|
||||||
|
const details = jsonSummary(item.details);
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
time: `${dateTime(item.createdAt)} CST`,
|
||||||
|
actor: item.actorName || item.actorUsername || item.actorUserId || 'system',
|
||||||
|
role: item.actorUserId ? '平台管理员' : '系统任务',
|
||||||
|
action: item.action || '-',
|
||||||
|
targetType: item.targetType || '-',
|
||||||
|
targetId: item.targetId || '',
|
||||||
|
target: item.tenantName || item.targetId || item.targetType || '-',
|
||||||
|
severity: mapValue(dictionaries.severityFromApi, item.details?.severity, item.details?.severity || '中'),
|
||||||
|
result: item.details?.result === 'failed' ? '失败' : '成功',
|
||||||
|
ip: item.ipAddress || '-',
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
alert(item) {
|
||||||
|
const evidence = Array.isArray(item.details?.evidence)
|
||||||
|
? item.details.evidence.map(String)
|
||||||
|
: Object.entries(item.details || {}).slice(0, 8).map(([key, value]) => `${key}: ${jsonSummary(value)}`);
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title || item.ruleName || '平台审计告警',
|
||||||
|
summary: item.summary || item.action || '',
|
||||||
|
severity: mapValue(dictionaries.severityFromApi, item.severity, item.severity || '-'),
|
||||||
|
status: item.status || 'open',
|
||||||
|
owner: item.acknowledgedByName || item.resolvedByName || item.tenantName || item.ruleName || '待认领',
|
||||||
|
targetType: item.targetType || '-',
|
||||||
|
targetId: item.targetId || '',
|
||||||
|
evidence,
|
||||||
|
updatedAt: dateTime(item.updatedAt || item.createdAt),
|
||||||
|
note: item.resolutionNote || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rule(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
severity: mapValue(dictionaries.severityFromApi, item.severity, item.severity || '-'),
|
||||||
|
enabled: item.enabled !== false,
|
||||||
|
window: item.conditions?.window || item.metadata?.window || '-',
|
||||||
|
threshold: item.conditions?.threshold || item.metadata?.threshold || '-',
|
||||||
|
target: item.description || (item.targetTypes || []).join('、') || '-',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
channel(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
code: item.channelCode,
|
||||||
|
name: item.name,
|
||||||
|
provider: mapValue(dictionaries.providerFromApi, item.provider, item.provider || 'Webhook'),
|
||||||
|
endpoint: safeEndpoint(item),
|
||||||
|
enabled: item.enabled !== false,
|
||||||
|
retry: Number(item.maxAttempts || item.metadata?.maxAttempts || 3),
|
||||||
|
minSeverity: mapValue(dictionaries.severityFromApi, item.minSeverity, item.minSeverity || '中'),
|
||||||
|
statusFilter: (item.statusFilter || []).join(','),
|
||||||
|
timeout: Number(item.timeoutSec || 10),
|
||||||
|
maxAttempts: Number(item.maxAttempts || item.metadata?.maxAttempts || 3),
|
||||||
|
lastStatus: '正常',
|
||||||
|
secretRef: item.secretRef || '',
|
||||||
|
endpointRedacted: true,
|
||||||
|
reminderTypes: item.reminderTypes || [],
|
||||||
|
reminderChannels: item.reminderChannels || [],
|
||||||
|
minReminderLevel: Number(item.minReminderLevel || 1),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
event(item, type = 'audit') {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
channelId: item.channelId,
|
||||||
|
subject: type === 'dunning'
|
||||||
|
? item.reminderMessage || item.invoiceNo || '催缴通知'
|
||||||
|
: item.alertTitle || item.alertAction || '审计告警通知',
|
||||||
|
status: mapValue(dictionaries.eventStatusFromApi, item.status, item.status || '-'),
|
||||||
|
attempts: Number(item.attempts || 0),
|
||||||
|
updatedAt: dateTime(item.updatedAt || item.sentAt || item.createdAt),
|
||||||
|
error: item.lastError || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function aggregateUsage(items) {
|
||||||
|
const records = new Map();
|
||||||
|
items.forEach(item => {
|
||||||
|
const period = monthOnly(item.periodStart || item.periodEnd);
|
||||||
|
const key = `${item.tenantId}:${period}`;
|
||||||
|
const record = records.get(key) || {
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
period,
|
||||||
|
source: item.metadata?.source === 'platform_usage_worker' ? 'Worker 自动采集' : '人工补录',
|
||||||
|
students: 0,
|
||||||
|
questions: 0,
|
||||||
|
storageGB: 0,
|
||||||
|
trafficTB: 0,
|
||||||
|
overage: [],
|
||||||
|
estimated: 0,
|
||||||
|
};
|
||||||
|
const metricMap = {
|
||||||
|
students: 'students',
|
||||||
|
questions: 'questions',
|
||||||
|
storage_gb: 'storageGB',
|
||||||
|
traffic_tb: 'trafficTB',
|
||||||
|
};
|
||||||
|
const field = metricMap[item.metricKey];
|
||||||
|
if (field) record[field] = Number(item.metricValue || 0);
|
||||||
|
records.set(key, record);
|
||||||
|
});
|
||||||
|
return Array.from(records.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPeriod() {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const values = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||||
|
return `${values.year}-${values.month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrapDescriptors = [
|
||||||
|
['overview', 'PlatformAdminOverviewController_overview', {}],
|
||||||
|
['plans', 'PlatformAdminOverviewController_plans', { query: { includeArchived: false } }],
|
||||||
|
['tenants', 'PlatformAdminTenantsController_list', { query: { limit: 200 } }],
|
||||||
|
['staff', 'PlatformAdminOverviewController_staff', { query: { limit: 200 } }],
|
||||||
|
['dunningChannels', 'PlatformAdminDunningChannelsController_channels', { query: { limit: 100 } }],
|
||||||
|
['dunningEvents', 'PlatformAdminDunningEventsController_events', { query: { limit: 100 } }],
|
||||||
|
['auditLogs', 'PlatformAdminAuditController_logs', { query: { limit: 200 } }],
|
||||||
|
['alerts', 'PlatformAdminAuditController_alerts', { query: { limit: 100 } }],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function bootstrap(options = {}) {
|
||||||
|
const descriptors = bootstrapDescriptors.map(([key, operationId, requestOptions]) => {
|
||||||
|
const nextOptions = { ...requestOptions, query: { ...(requestOptions.query || {}) } };
|
||||||
|
if (key === 'banks') nextOptions.query.includeTenantBanks = Boolean(options.includeTenantBanks);
|
||||||
|
return [key, operationId, nextOptions];
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
descriptors.map(([, operationId, requestOptions]) => request(operationId, requestOptions)),
|
||||||
|
);
|
||||||
|
const payloads = {};
|
||||||
|
const errors = {};
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const [key] = descriptors[index];
|
||||||
|
if (result.status === 'fulfilled') payloads[key] = result.value;
|
||||||
|
else errors[key] = result.reason;
|
||||||
|
});
|
||||||
|
return { payloads, errors, requested: descriptors.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptBootstrap(payloads, currentData = {}) {
|
||||||
|
const patch = {};
|
||||||
|
if (payloads.overview) patch.apiOverview = payloads.overview.item || payloads.overview;
|
||||||
|
if (payloads.permissions?.item) patch.apiPermissions = payloads.permissions.item;
|
||||||
|
if (Array.isArray(payloads.plans?.items)) {
|
||||||
|
patch.plans = payloads.plans.items.map(item => adapters.plan(item, currentById(currentData.plans, item.code)));
|
||||||
|
}
|
||||||
|
const planItems = patch.plans || currentData.plans || [];
|
||||||
|
const planMap = Object.fromEntries(planItems.map(item => [item.code, item]));
|
||||||
|
if (Array.isArray(payloads.tenants?.items)) {
|
||||||
|
patch.tenants = payloads.tenants.items.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap));
|
||||||
|
}
|
||||||
|
if (Array.isArray(payloads.staff?.items)) patch.staff = payloads.staff.items.map(adapters.staff);
|
||||||
|
if (Array.isArray(payloads.invoices?.items)) patch.invoices = payloads.invoices.items.map(adapters.invoice);
|
||||||
|
if (Array.isArray(payloads.subscriptions?.items)) {
|
||||||
|
patch.subscriptions = payloads.subscriptions.items
|
||||||
|
.filter(item => item.subscriptionId)
|
||||||
|
.map(adapters.subscription);
|
||||||
|
}
|
||||||
|
if (Array.isArray(payloads.usage?.items)) patch.usage = aggregateUsage(payloads.usage.items);
|
||||||
|
if (Array.isArray(payloads.usageCandidates?.items) && patch.usage) {
|
||||||
|
const usageByKey = new Map(patch.usage.map(item => [`${item.tenantId}:${item.period}`, item]));
|
||||||
|
payloads.usageCandidates.items.forEach(candidate => {
|
||||||
|
const record = usageByKey.get(`${candidate.tenantId}:${monthOnly(candidate.periodStart)}`);
|
||||||
|
if (!record) return;
|
||||||
|
record.estimated = centsToYuan(candidate.totalCents);
|
||||||
|
record.overage = (candidate.items || []).map(item => item.description || item.itemType).filter(Boolean);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(payloads.reminders?.items)) patch.reminders = payloads.reminders.items.map(adapters.reminder);
|
||||||
|
if (Array.isArray(payloads.dunningChannels?.items)) patch.dunningChannels = payloads.dunningChannels.items.map(adapters.channel);
|
||||||
|
if (Array.isArray(payloads.dunningEvents?.items)) patch.dunningEvents = payloads.dunningEvents.items.map(item => adapters.event(item, 'dunning'));
|
||||||
|
if (Array.isArray(payloads.banks?.items)) {
|
||||||
|
patch.banks = payloads.banks.items.map(item => adapters.bank(item, currentById(currentData.banks, item.id)));
|
||||||
|
}
|
||||||
|
if (Array.isArray(payloads.grants?.items)) patch.grants = payloads.grants.items.map(adapters.grant);
|
||||||
|
if (Array.isArray(payloads.bankSync?.items)) patch.bankSync = payloads.bankSync.items.map(adapters.sync);
|
||||||
|
if (Array.isArray(payloads.auditLogs?.items)) patch.auditLogs = payloads.auditLogs.items.map(adapters.audit);
|
||||||
|
if (Array.isArray(payloads.alertRules?.items)) patch.alertRules = payloads.alertRules.items.map(adapters.rule);
|
||||||
|
if (Array.isArray(payloads.alerts?.items)) patch.alerts = payloads.alerts.items.map(adapters.alert);
|
||||||
|
if (Array.isArray(payloads.auditChannels?.items)) patch.auditChannels = payloads.auditChannels.items.map(adapters.channel);
|
||||||
|
if (Array.isArray(payloads.notificationEvents?.items)) {
|
||||||
|
patch.notificationEvents = payloads.notificationEvents.items.map(item => adapters.event(item, 'audit'));
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tenantDetail(tenantId) {
|
||||||
|
const payload = await request('PlatformAdminTenantsController_detail', { query: { tenantId } });
|
||||||
|
const item = payload.item || {};
|
||||||
|
return {
|
||||||
|
tenant: item.tenant || null,
|
||||||
|
domains: item.domains || [],
|
||||||
|
subscriptions: (item.subscriptions || []).map(adapters.subscription),
|
||||||
|
invoices: (item.invoices || []).map(adapters.invoice),
|
||||||
|
usage: aggregateUsage(item.usage || []),
|
||||||
|
billingProfile: item.tenant ? {
|
||||||
|
billingName: item.tenant.billingName || '',
|
||||||
|
taxId: item.tenant.taxId || '',
|
||||||
|
contactName: item.tenant.contactName || '',
|
||||||
|
phone: item.tenant.contactPhone || '',
|
||||||
|
email: item.tenant.contactEmail || '',
|
||||||
|
billingAddress: item.tenant.billingAddress || '',
|
||||||
|
invoiceTitle: item.tenant.invoiceTitle || '',
|
||||||
|
type: ({ special_vat: '增值税专用发票', normal_vat: '增值税普通发票', none: '不开票' })[item.tenant.invoiceType] || item.tenant.invoiceType || '',
|
||||||
|
bankName: item.tenant.bankName || '',
|
||||||
|
bankAccountMasked: item.tenant.bankAccountMasked || '',
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExport(payload) {
|
||||||
|
const item = payload?.item;
|
||||||
|
if (!item?.contentBase64) {
|
||||||
|
throw new PlatformApiError('审计导出响应缺少文件内容', { code: 'AUDIT_EXPORT_INVALID' });
|
||||||
|
}
|
||||||
|
const bytes = Uint8Array.from(atob(item.contentBase64), character => character.charCodeAt(0));
|
||||||
|
const blob = new Blob([bytes], { type: item.mimeType || 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = item.filename || `platform-audit.${item.format || 'csv'}`;
|
||||||
|
anchor.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.GongxuePlatformApi = Object.freeze({
|
||||||
|
PlatformApiError,
|
||||||
|
adapters,
|
||||||
|
adaptBootstrap,
|
||||||
|
bootstrap,
|
||||||
|
buildBody,
|
||||||
|
dictionaries,
|
||||||
|
downloadExport,
|
||||||
|
isEnabled,
|
||||||
|
operationCatalog,
|
||||||
|
periodBounds,
|
||||||
|
publicConfig,
|
||||||
|
request,
|
||||||
|
tenantDetail,
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
})();
|
||||||
172
Tiku.Api/wwwroot/platform-admin/platform-auth.js
Normal file
172
Tiku.Api/wwwroot/platform-admin/platform-auth.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
(() => {
|
||||||
|
const ACCESS_TOKEN_KEY = 'tiku_platform_access_token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'tiku_platform_refresh_token';
|
||||||
|
const ACCESS_EXPIRES_KEY = 'tiku_platform_access_expires_at';
|
||||||
|
const USER_KEY = 'tiku_platform_user';
|
||||||
|
const runtime = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {};
|
||||||
|
let challengeToken = '';
|
||||||
|
let pendingResolve;
|
||||||
|
|
||||||
|
function apiUrl(path) {
|
||||||
|
return `${String(runtime.apiBaseUrl || '').replace(/\/+$/, '')}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
const response = await fetch(apiUrl(path), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'omit',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = {};
|
||||||
|
if (text) {
|
||||||
|
try { payload = JSON.parse(text); } catch { payload = { detail: text.slice(0, 500) }; }
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(payload.detail || payload.title || payload.message || `请求失败(HTTP ${response.status})`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gate() { return document.querySelector('#platformAuthGate'); }
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>'"]/g, character => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||||
|
})[character]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(title, description, content) {
|
||||||
|
gate().innerHTML = `<section class="platform-auth-card" role="dialog" aria-modal="true"><header><img src="./assets/logo.png" alt="" /><div><h1>${title}</h1><p>${description}</p></div></header>${content}<p class="platform-auth-error" id="platformAuthError" role="alert"></p></section>`;
|
||||||
|
gate().hidden = false;
|
||||||
|
document.querySelector('.platform-app')?.setAttribute('inert', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(error) {
|
||||||
|
const target = document.querySelector('#platformAuthError');
|
||||||
|
if (target) target.textContent = error?.message || '操作失败,请重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(form, busy) {
|
||||||
|
form.querySelectorAll('button,input').forEach(control => { control.disabled = busy; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeAuthenticated(result) {
|
||||||
|
const user = result?.user;
|
||||||
|
const tokens = user?.tokens;
|
||||||
|
if (!tokens?.accessToken || !tokens?.refreshToken) throw new Error('登录响应缺少令牌');
|
||||||
|
sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
|
||||||
|
sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||||
|
sessionStorage.setItem(ACCESS_EXPIRES_KEY, tokens.accessTokenExpiresAt || '');
|
||||||
|
sessionStorage.setItem(USER_KEY, JSON.stringify({ userId: user.userId, email: user.email, name: user.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeGate() {
|
||||||
|
gate().hidden = true;
|
||||||
|
gate().innerHTML = '';
|
||||||
|
document.querySelector('.platform-app')?.removeAttribute('inert');
|
||||||
|
pendingResolve?.(true);
|
||||||
|
pendingResolve = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishAuthentication(result, recoveryCodes = []) {
|
||||||
|
storeAuthenticated(result);
|
||||||
|
if (recoveryCodes.length) {
|
||||||
|
shell('保存恢复代码', '这些代码只显示一次,请保存到安全位置。', `<div class="platform-auth-recovery">${recoveryCodes.map(code => `<div>${escapeHtml(code)}</div>`).join('')}</div><p class="auth-help">保存后再进入平台控制台。</p><button type="button" id="platformAuthContinue">我已保存,进入控制台</button>`);
|
||||||
|
document.querySelector('#platformAuthContinue').addEventListener('click', completeGate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
completeGate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthenticationResult(result) {
|
||||||
|
challengeToken = result.challengeToken || '';
|
||||||
|
if (result.status === 'authenticated') { finishAuthentication(result); return; }
|
||||||
|
if (result.status === 'password_change_required') { renderPasswordChange(); return; }
|
||||||
|
if (result.status === 'mfa_enrollment_required') { await renderMfaEnrollment(); return; }
|
||||||
|
if (result.status === 'mfa_required') { renderMfaVerification(); return; }
|
||||||
|
throw new Error(`不支持的认证状态:${result.status || 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogin() {
|
||||||
|
shell('平台管理员登录', '连接真实 PostgreSQL 与 ASP.NET Core API。', `<form id="platformLoginForm"><label>平台账号<input name="identifier" type="email" autocomplete="username" required /></label><label>密码<input name="password" type="password" autocomplete="current-password" minlength="10" required /></label><button type="submit">登录</button></form>`);
|
||||||
|
document.querySelector('#platformLoginForm').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
setBusy(form, true);
|
||||||
|
try {
|
||||||
|
const result = await post('/api/auth/login/password', { realm: 'platform', identifier: form.elements.identifier.value.trim(), password: form.elements.password.value });
|
||||||
|
await handleAuthenticationResult(result);
|
||||||
|
} catch (error) { showError(error); setBusy(form, false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPasswordChange() {
|
||||||
|
shell('设置正式密码', '首次登录必须先替换临时密码。', `<form id="platformPasswordForm"><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="10" required /></label><label>确认新密码<input name="confirmPassword" type="password" autocomplete="new-password" minlength="10" required /></label><button type="submit">更新密码并继续</button></form>`);
|
||||||
|
document.querySelector('#platformPasswordForm').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
if (form.elements.newPassword.value !== form.elements.confirmPassword.value) { showError(new Error('两次输入的密码不一致')); return; }
|
||||||
|
setBusy(form, true);
|
||||||
|
try { await handleAuthenticationResult(await post('/api/auth/password/change-required', { challengeToken, newPassword: form.elements.newPassword.value })); }
|
||||||
|
catch (error) { showError(error); setBusy(form, false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMfaEnrollment() {
|
||||||
|
const setup = await post('/api/auth/mfa/totp/setup', { challengeToken });
|
||||||
|
shell('绑定双重验证', '在认证器中添加密钥,然后输入当前 6 位验证码。', `<code>${escapeHtml(setup.sharedKey)}</code><p class="auth-help">也可在支持的认证器中导入:${escapeHtml(setup.authenticatorUri)}</p><form id="platformMfaForm"><label>动态验证码<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required /></label><button type="submit">确认绑定</button></form>`);
|
||||||
|
document.querySelector('#platformMfaForm').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
setBusy(form, true);
|
||||||
|
try {
|
||||||
|
const result = await post('/api/auth/mfa/totp/confirm', { challengeToken, code: form.elements.code.value.trim() });
|
||||||
|
finishAuthentication(result.authentication, result.recoveryCodes || []);
|
||||||
|
} catch (error) { showError(error); setBusy(form, false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMfaVerification() {
|
||||||
|
shell('双重验证', '输入认证器中的当前 6 位验证码。', `<form id="platformMfaForm"><label>动态验证码<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus /></label><button type="submit">验证并登录</button></form>`);
|
||||||
|
document.querySelector('#platformMfaForm').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
setBusy(form, true);
|
||||||
|
try { await handleAuthenticationResult(await post('/api/auth/mfa/totp/verify', { challengeToken, code: form.elements.code.value.trim() })); }
|
||||||
|
catch (error) { showError(error); setBusy(form, false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ACCESS_EXPIRES_KEY, USER_KEY].forEach(key => sessionStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSession() {
|
||||||
|
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
const expiresAt = Date.parse(sessionStorage.getItem(ACCESS_EXPIRES_KEY) || '');
|
||||||
|
return Boolean(token) && Number.isFinite(expiresAt) && expiresAt > Date.now() + 15_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSession() {
|
||||||
|
if (hasSession()) return true;
|
||||||
|
clearSession();
|
||||||
|
renderLogin();
|
||||||
|
return new Promise(resolve => { pendingResolve = resolve; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
try { if (refreshToken) await post('/api/auth/logout', { refreshToken }); } catch { /* Clear the browser session even when revocation cannot be reached. */ }
|
||||||
|
clearSession();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.GongxuePlatformAuth = Object.freeze({
|
||||||
|
getAccessToken: () => sessionStorage.getItem(ACCESS_TOKEN_KEY) || '',
|
||||||
|
getUser: () => {
|
||||||
|
try { return JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
|
||||||
|
},
|
||||||
|
logout,
|
||||||
|
requireSession,
|
||||||
|
});
|
||||||
|
})();
|
||||||
24
Tiku.Api/wwwroot/platform-admin/runtime-config.js
Normal file
24
Tiku.Api/wwwroot/platform-admin/runtime-config.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* 平台端公开运行时配置。
|
||||||
|
*
|
||||||
|
* 默认连接同源后端。其他部署环境可在本文件之前注入同名对象,
|
||||||
|
* 或在部署时替换本文件。不要把 access token、service role key 或任何服务端密钥
|
||||||
|
* 写入静态文件;getAccessToken 应从当前平台登录会话中按需读取短期 JWT。
|
||||||
|
*
|
||||||
|
* window.GONGXUE_PLATFORM_RUNTIME_CONFIG = {
|
||||||
|
* mode: 'api',
|
||||||
|
* apiBaseUrl: 'https://api.example.com',
|
||||||
|
* fallbackToMock: true,
|
||||||
|
* timeoutMs: 10000,
|
||||||
|
* getAccessToken: async () => {
|
||||||
|
* return sessionStorage.getItem('tiku_platform_access_token') || '';
|
||||||
|
* },
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
window.GONGXUE_PLATFORM_RUNTIME_CONFIG = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {
|
||||||
|
mode: 'api',
|
||||||
|
apiBaseUrl: '',
|
||||||
|
fallbackToMock: false,
|
||||||
|
timeoutMs: 10000,
|
||||||
|
getAccessToken: async () => window.GongxuePlatformAuth?.getAccessToken() || '',
|
||||||
|
};
|
||||||
246
Tiku.Api/wwwroot/platform-admin/spec-contract.js
Normal file
246
Tiku.Api/wwwroot/platform-admin/spec-contract.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
(() => {
|
||||||
|
const actions = [
|
||||||
|
['P-01', 'act.p-01.01', '进入租户/账务/题库/审计', 'command', 'native', 'nav', '[data-nav="tenants"],[data-nav="billing"],[data-nav="bank"],[data-nav="audit"]', 'PlatformAdminOverviewController_overview', 'allow'],
|
||||||
|
['P-01', 'act.p-01.02', '确认/解决告警', 'command', 'native', 'mutation', '[data-alert-detail],[data-action="transition-alert"]', 'PlatformAdminAuditController_alerts', 'allow'],
|
||||||
|
['P-01', 'act.p-01.03', '导出审计', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||||
|
['P-01', 'act.p-01.04', '刷新', 'command', 'native', 'read', '[data-action="refresh"]', 'PlatformAdminOverviewController_overview', 'allow'],
|
||||||
|
['P-02', 'act.p-02.01', '搜索', 'command', 'native', 'read', '#tenantSearch,[data-action="apply-tenant-query"]', 'PlatformAdminTenantsController_list', 'allow'],
|
||||||
|
['P-02', 'act.p-02.02', '创建租户', 'command', 'native', 'mutation', '[data-action="create-tenant"],[form="createTenantForm"]', 'PlatformAdminTenantsController_create', 'block'],
|
||||||
|
['P-02', 'act.p-02.03', '打开详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminTenantsController_list', 'allow'],
|
||||||
|
['P-02', 'act.p-02.04', '准备暂停/恢复', 'destructive', 'native', 'mutation', '[data-action="prepare-tenant-status"],[form="tenantStatusForm"]', 'PlatformAdminTenantsController_status', 'block'],
|
||||||
|
['P-02', 'act.p-02.05', '去账务', 'command', 'native', 'nav', '[data-action="tenant-to-billing"]', 'PlatformAdminBillingController_invoices', 'allow'],
|
||||||
|
['P-03', 'act.p-03.01', '修改状态', 'command', 'native', 'mutation', '[data-action="prepare-tenant-status"],[form="tenantStatusForm"]', 'PlatformAdminTenantsController_status', 'block'],
|
||||||
|
['P-03', 'act.p-03.02', '编辑账务资料', 'command', 'native', 'nav', '[data-action="edit-billing-profile"]', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||||
|
['P-03', 'act.p-03.03', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription-for-tenant"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'],
|
||||||
|
['P-03', 'act.p-03.04', '查看账单/用量', 'command', 'native', 'nav', '[data-action="tenant-to-billing"],[data-action="tenant-to-usage"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||||
|
['P-03', 'act.p-03.05', '跳转租户门户(只做链接,不冒充登录)', 'command', 'native', 'nav', '[data-action="open-tenant-portal"]', 'PlatformAdminTenantsController_detail', 'allow'],
|
||||||
|
['P-04', 'act.p-04.01', '编辑', 'command', 'native', 'mutation', '#billingProfileForm', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||||
|
['P-04', 'act.p-04.02', '保存', 'command', 'native', 'mutation', '[form="billingProfileForm"]', 'PlatformAdminTenantsController_billingProfile', 'block'],
|
||||||
|
['P-04', 'act.p-04.03', '查看关联账单', 'command', 'native', 'nav', '[data-action="billing-profile-to-invoices"]', 'PlatformAdminTenantsController_billingProfile', 'allow'],
|
||||||
|
['P-05', 'act.p-05.01', '选择套餐', 'command', 'native', 'mutation', '[data-action="select-plan"]', 'PlatformAdminOverviewController_plans', 'block'],
|
||||||
|
['P-05', 'act.p-05.02', '创建订阅', 'command', 'native', 'mutation', '[data-action="create-subscription"],[form="subscriptionForm"]', 'PlatformAdminBillingController_createSubscription', 'block'],
|
||||||
|
['P-05', 'act.p-05.03', '进入账单候选', 'command', 'native', 'nav', '[data-action="subscription-to-billing"]', 'PlatformAdminBillingController_createSubscription', 'allow'],
|
||||||
|
['P-05', 'act.p-05.04', '查看租户详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_createSubscription', 'allow'],
|
||||||
|
['P-06', 'act.p-06.01', '查询', 'command', 'contract', 'read', '[data-action="apply-invoice-query"]', 'PlatformAdminBillingController_invoices', 'allow'],
|
||||||
|
['P-06', 'act.p-06.02', '手工创建', 'command', 'native', 'mutation', '[data-action="create-invoice"],[form="invoiceForm"]', 'PlatformAdminBillingController_createInvoice', 'block'],
|
||||||
|
['P-06', 'act.p-06.03', '预览候选', 'command', 'native', 'read', '[data-action="preview-invoice-candidates"]', 'PlatformAdminBillingController_subscriptionCandidates', 'block'],
|
||||||
|
['P-06', 'act.p-06.04', '单个生成', 'async', 'contract', 'task', '[data-action="generate-subscription-invoice"]', 'PlatformAdminBillingController_fromSubscription', 'block'],
|
||||||
|
['P-06', 'act.p-06.05', '批量 dry-run', 'command', 'native', 'read', '[data-action="run-invoice-dry-run"]', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'block'],
|
||||||
|
['P-06', 'act.p-06.06', '确认生成', 'async', 'native', 'task', '[data-action="confirm-generate-candidates"]', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'block'],
|
||||||
|
['P-06', 'act.p-06.07', '打开租户', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_invoices', 'allow'],
|
||||||
|
['P-07', 'act.p-07.01', '记录用量', 'command', 'native', 'mutation', '[data-action="record-usage"],[form="usageForm"]', 'PlatformAdminBillingController_recordUsage', 'block'],
|
||||||
|
['P-07', 'act.p-07.02', '查询', 'command', 'native', 'read', '#usagePeriodFilter', 'PlatformAdminBillingController_usage', 'allow'],
|
||||||
|
['P-07', 'act.p-07.03', '预览超额候选', 'command', 'native', 'read', '[data-action="preview-overage"]', 'PlatformAdminBillingController_usageCandidates', 'block'],
|
||||||
|
['P-07', 'act.p-07.04', 'dry-run', 'command', 'native', 'read', '[data-action="run-overage-dry-run"]', 'PlatformAdminBillingController_fromUsage', 'block'],
|
||||||
|
['P-07', 'act.p-07.05', '生成超额账单', 'async', 'native', 'task', '[data-action="confirm-overage-invoices"]', 'PlatformAdminBillingController_fromUsage', 'block'],
|
||||||
|
['P-07', 'act.p-07.06', '跳租户详情', 'command', 'native', 'nav', '[data-tenant-detail]', 'PlatformAdminBillingController_usage', 'allow'],
|
||||||
|
['P-08', 'act.p-08.01', '确认收款', 'command', 'native', 'mutation', '[data-action="confirm-payment"],[form="paymentForm"]', 'PlatformAdminBillingController_confirmPayment', 'block'],
|
||||||
|
['P-08', 'act.p-08.02', '预览逾期', 'command', 'native', 'read', '[data-action="preview-overdue"]', 'PlatformAdminBillingController_processOverdue', 'block'],
|
||||||
|
['P-08', 'act.p-08.03', '生成催缴', 'async', 'native', 'task', '[data-action="confirm-overdue-process"],[data-action="generate-reminder"]', 'PlatformAdminBillingController_processOverdue', 'block'],
|
||||||
|
['P-08', 'act.p-08.04', '查询提醒', 'command', 'native', 'read', '[data-billing-tab="reminders"]', 'PlatformAdminBillingController_reminders', 'allow'],
|
||||||
|
['P-08', 'act.p-08.05', '新增/编辑/启停渠道', 'command', 'native', 'mutation', '[data-action="new-dunning-channel"],[data-action="edit-dunning-channel"],[data-action="toggle-dunning-channel"]', 'PlatformAdminDunningChannelsController_upsertChannel', 'block'],
|
||||||
|
['P-08', 'act.p-08.06', '查看通知事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminDunningEventsController_events', 'allow'],
|
||||||
|
['P-09', 'act.p-09.01', '搜索', 'command', 'contract', 'read', '[data-action="apply-bank-search"]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||||
|
['P-09', 'act.p-09.02', '含租户库切换', 'command', 'native', 'read', '#includeTenantBanks', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||||
|
['P-09', 'act.p-09.03', '新建/编辑/禁用授权', 'destructive', 'native', 'mutation', '[data-action="new-grant"],[data-action="edit-grant"],[data-action="toggle-grant"]', 'PlatformAdminQuestionBanksController_upsertGrant', 'block'],
|
||||||
|
['P-09', 'act.p-09.04', '选择题库', 'command', 'native', 'read', '[data-bank-select]', 'PlatformAdminQuestionBanksController_banks', 'allow'],
|
||||||
|
['P-09', 'act.p-09.05', '查看租户采用/同步', 'async', 'contract', 'task', '[data-action="preview-bank-sync"]', 'PlatformAdminQuestionBanksController_syncStatus', 'allow'],
|
||||||
|
['P-10', 'act.p-10.01', '新建/编辑', 'command', 'native', 'mutation', '[data-action="new-staff"],[data-action="edit-staff"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||||
|
['P-10', 'act.p-10.02', '全选/分组授权', 'command', 'native', 'mutation', '[data-action="select-permission-group"]', 'PlatformAdminOverviewController_permissions', 'block'],
|
||||||
|
['P-10', 'act.p-10.03', '启用/禁用', 'destructive', 'native', 'mutation', '[data-action="toggle-staff"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||||
|
['P-10', 'act.p-10.04', '禁用时撤销会话', 'destructive', 'native', 'audit', '[data-action="execute-staff-disable"]', 'PlatformAdminOverviewController_updateStaffStatus', 'block'],
|
||||||
|
['P-10', 'act.p-10.05', '清空表单', 'command', 'contract', 'mutation', '[data-action="clear-staff-form"]', 'PlatformAdminOverviewController_upsertStaff', 'block'],
|
||||||
|
['P-11', 'act.p-11.01', '筛选', 'command', 'native', 'read', '#auditSearch,#auditRangeFilter,[data-audit-severity]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||||
|
['P-11', 'act.p-11.02', '查看详情', 'command', 'native', 'read', '[data-audit-detail]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||||
|
['P-11', 'act.p-11.03', '导出 CSV/JSON', 'async', 'native', 'task', '[data-action="export-audit"]', 'PlatformAdminAuditController_exportLogs', 'allow'],
|
||||||
|
['P-11', 'act.p-11.04', '复制 ID', 'command', 'native', 'read', '[data-copy]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||||
|
['P-11', 'act.p-11.05', '跳目标对象', 'command', 'native', 'nav', '[data-action="jump-audit-target"],[data-action="jump-related-alert"]', 'PlatformAdminAuditController_logs', 'allow'],
|
||||||
|
['P-12', 'act.p-12.01', '确认/解决告警', 'command', 'native', 'mutation', '[data-action="transition-alert"]', 'PlatformAdminAuditController_updateAlert', 'allow'],
|
||||||
|
['P-12', 'act.p-12.02', '查看证据', 'command', 'native', 'read', '[data-alert-detail]', 'PlatformAdminAuditController_alerts', 'allow'],
|
||||||
|
['P-12', 'act.p-12.03', '新建/编辑/启停通知渠道', 'command', 'native', 'mutation', '[data-action="new-audit-channel"],[data-action="edit-audit-channel"],[data-action="toggle-audit-channel"]', 'PlatformAdminAuditController_upsertChannel', 'block'],
|
||||||
|
['P-12', 'act.p-12.04', '查看失败事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminAuditController_events', 'allow'],
|
||||||
|
].map(([pageId, id, label, kind, mode, effect, selector, operationId, mobile]) => ({ pageId, id, label, kind, mode, effect, selector, operationIds: [operationId], mobile, destructive: kind === 'destructive', implemented: true }));
|
||||||
|
|
||||||
|
const operationRows = [
|
||||||
|
['P-01', 'PlatformAdminOverviewController_overview', 'GET', '/api/platform-admin/overview'],
|
||||||
|
['P-01', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
|
||||||
|
['P-01', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
|
||||||
|
['P-02', 'PlatformAdminTenantsController_list', 'GET', '/api/platform-admin/tenants'],
|
||||||
|
['P-02', 'PlatformAdminTenantsController_create', 'POST', '/api/platform-admin/tenants'],
|
||||||
|
['P-03', 'PlatformAdminTenantsController_detail', 'GET', '/api/platform-admin/tenants/detail'],
|
||||||
|
['P-03', 'PlatformAdminTenantsController_status', 'PATCH', '/api/platform-admin/tenants/status'],
|
||||||
|
['P-03', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
|
||||||
|
['P-03', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/subscriptions'],
|
||||||
|
['P-04', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'],
|
||||||
|
['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/plans'],
|
||||||
|
['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/subscriptions'],
|
||||||
|
['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/invoices'],
|
||||||
|
['P-06', 'PlatformAdminBillingController_createInvoice', 'POST', '/api/platform-admin/invoices'],
|
||||||
|
['P-06', 'PlatformAdminBillingController_subscriptionCandidates', 'GET', '/api/platform-admin/invoices/subscription-candidates'],
|
||||||
|
['P-06', 'PlatformAdminBillingController_fromSubscription', 'POST', '/api/platform-admin/invoices/from-subscription'],
|
||||||
|
['P-06', 'PlatformAdminBillingController_fromSubscriptionsBatch', 'POST', '/api/platform-admin/invoices/from-subscriptions-batch'],
|
||||||
|
['P-07', 'PlatformAdminBillingController_usage', 'GET', '/api/platform-admin/usage'],
|
||||||
|
['P-07', 'PlatformAdminBillingController_recordUsage', 'POST', '/api/platform-admin/usage'],
|
||||||
|
['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/invoices/usage-overage-candidates'],
|
||||||
|
['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/invoices/from-usage-overage'],
|
||||||
|
['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/invoices/payments/manual-confirm'],
|
||||||
|
['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/invoices/process-overdue'],
|
||||||
|
['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/invoices/reminders'],
|
||||||
|
['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/dunning-notification-channels'],
|
||||||
|
['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/dunning-notification-channels'],
|
||||||
|
['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/dunning-notification-events'],
|
||||||
|
['P-09', 'PlatformAdminQuestionBanksController_banks', 'GET', '/api/platform-admin/question-banks'],
|
||||||
|
['P-09', 'PlatformAdminQuestionBanksController_grants', 'GET', '/api/platform-admin/question-bank-grants'],
|
||||||
|
['P-09', 'PlatformAdminQuestionBanksController_upsertGrant', 'PUT', '/api/platform-admin/question-bank-grants'],
|
||||||
|
['P-09', 'PlatformAdminQuestionBanksController_syncStatus', 'GET', '/api/platform-admin/question-bank-sync-status'],
|
||||||
|
['P-10', 'PlatformAdminOverviewController_permissions', 'GET', '/api/platform-admin/permissions'],
|
||||||
|
['P-10', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'],
|
||||||
|
['P-10', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'],
|
||||||
|
['P-10', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'],
|
||||||
|
['P-11', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'],
|
||||||
|
['P-11', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'],
|
||||||
|
['P-12', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'],
|
||||||
|
['P-12', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'],
|
||||||
|
['P-12', 'PlatformAdminAuditController_updateAlert', 'POST', '/api/platform-admin/audit-alerts/status'],
|
||||||
|
['P-12', 'PlatformAdminAuditController_channels', 'GET', '/api/platform-admin/audit-notification-channels'],
|
||||||
|
['P-12', 'PlatformAdminAuditController_upsertChannel', 'PUT', '/api/platform-admin/audit-notification-channels'],
|
||||||
|
['P-12', 'PlatformAdminAuditController_events', 'GET', '/api/platform-admin/audit-notification-events'],
|
||||||
|
];
|
||||||
|
const operations = operationRows.map(([pageId, operationId, method, path]) => ({ pageId, operationId, method, path }));
|
||||||
|
const operationById = Object.fromEntries(operations.map(operation => [operation.operationId, operation]));
|
||||||
|
|
||||||
|
const navEdges = [
|
||||||
|
['nav.p-01.p-02', 'P-01', 'P-02', '[data-nav="tenants"]'],
|
||||||
|
['nav.p-01.p-06', 'P-01', 'P-06', '[data-nav="billing"]'],
|
||||||
|
['nav.p-01.p-12', 'P-01', 'P-12', '[data-nav="alerts"],[data-alert-detail]'],
|
||||||
|
['nav.p-02.p-03', 'P-02', 'P-03', '[data-tenant-detail]'],
|
||||||
|
['nav.p-03.p-04', 'P-03', 'P-04', '[data-action="edit-billing-profile"]'],
|
||||||
|
['nav.p-03.p-05', 'P-03', 'P-05', '[data-action="create-subscription-for-tenant"]'],
|
||||||
|
['nav.p-03.p-06', 'P-03', 'P-06', '[data-action="tenant-to-billing"]'],
|
||||||
|
['nav.p-05.p-06', 'P-05', 'P-06', '[data-action="subscription-to-billing"]'],
|
||||||
|
['nav.p-05.p-07', 'P-05', 'P-07', '[data-action="subscriptions-to-usage"]'],
|
||||||
|
['nav.p-06.p-08', 'P-06', 'P-08', '[data-nav="dunning"]'],
|
||||||
|
['nav.p-11.p-12', 'P-11', 'P-12', '[data-action="jump-related-alert"]'],
|
||||||
|
].map(([id, fromPageId, toPageId, selector]) => ({ id, fromPageId, toPageId, selector, implemented: true }));
|
||||||
|
|
||||||
|
const supportingReads = [
|
||||||
|
{ pageId: 'P-09', selector: '[data-action="sync-detail"],[data-action="refresh-bank-sync"]', operationId: 'PlatformAdminQuestionBanksController_syncStatus' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const trace = [];
|
||||||
|
const decoratedActionIds = new Set();
|
||||||
|
const selectorCollisions = new Set();
|
||||||
|
const explicitResultActionIds = new Set(['act.p-06.01', 'act.p-06.04', 'act.p-09.01', 'act.p-09.05']);
|
||||||
|
function pushTrace(entry) {
|
||||||
|
trace.push({ at: new Date().toISOString(), ...entry });
|
||||||
|
if (trace.length > 120) trace.splice(0, trace.length - 120);
|
||||||
|
document.documentElement.dataset.platformContractEvents = String(trace.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorate(root = document, pageId = '') {
|
||||||
|
const shell = root.querySelector?.('.page-shell');
|
||||||
|
if (shell && pageId) {
|
||||||
|
const pageOperations = operations.filter(operation => operation.pageId === pageId);
|
||||||
|
shell.dataset.specPageId = pageId;
|
||||||
|
shell.dataset.operationIds = pageOperations.map(operation => operation.operationId).join(' ');
|
||||||
|
shell.dataset.operationContracts = pageOperations.map(operation => `${operation.method} ${operation.path}`).join(' | ');
|
||||||
|
}
|
||||||
|
actions.filter(action => action.pageId === pageId).forEach(action => {
|
||||||
|
root.querySelectorAll?.(action.selector).forEach(element => {
|
||||||
|
const existingActionId = element.dataset.specActionId;
|
||||||
|
if (existingActionId && existingActionId !== action.id) selectorCollisions.add(`${existingActionId} -> ${action.id}`);
|
||||||
|
element.dataset.specActionId = action.id;
|
||||||
|
element.dataset.specActionMode = action.mode;
|
||||||
|
element.dataset.specEffect = action.effect;
|
||||||
|
element.dataset.mobileBoundary = action.mobile;
|
||||||
|
const operation = operationById[action.operationIds[0]];
|
||||||
|
if (operation) {
|
||||||
|
element.dataset.operationId = operation.operationId;
|
||||||
|
element.dataset.operationMethod = operation.method;
|
||||||
|
element.dataset.operationPath = operation.path;
|
||||||
|
}
|
||||||
|
decoratedActionIds.add(action.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
supportingReads.filter(read => read.pageId === pageId).forEach(read => {
|
||||||
|
const operation = operationById[read.operationId];
|
||||||
|
root.querySelectorAll?.(read.selector).forEach(element => {
|
||||||
|
element.dataset.specSupportingRead = 'true';
|
||||||
|
element.dataset.specEffect = 'read';
|
||||||
|
if (operation) {
|
||||||
|
element.dataset.operationId = operation.operationId;
|
||||||
|
element.dataset.operationMethod = operation.method;
|
||||||
|
element.dataset.operationPath = operation.path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
navEdges.filter(edge => edge.fromPageId === pageId).forEach(edge => {
|
||||||
|
root.querySelectorAll?.(edge.selector).forEach(element => {
|
||||||
|
element.dataset.navEdgeId = edge.id;
|
||||||
|
element.dataset.navTargetPage = edge.toPageId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.__PLATFORM_SPEC_COVERAGE__ = coverage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventContract(target) {
|
||||||
|
const element = target?.closest?.('[data-spec-action-id]');
|
||||||
|
if (!element) return null;
|
||||||
|
return { element, actionId: element.dataset.specActionId, operationId: element.dataset.operationId || '', requestId: `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', event => {
|
||||||
|
const contract = eventContract(event.target);
|
||||||
|
if (!contract) return;
|
||||||
|
contract.element.dataset.contractRequestId = contract.requestId;
|
||||||
|
pushTrace({ phase: 'before', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId });
|
||||||
|
setTimeout(() => pushTrace({ phase: 'after', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId, result: 'handled', pageId: document.querySelector('.page-shell')?.dataset.specPageId || '' }), 0);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('change', event => {
|
||||||
|
const contract = eventContract(event.target);
|
||||||
|
if (!contract) return;
|
||||||
|
pushTrace({ phase: 'after', actionId: contract.actionId, operationId: contract.operationId, requestId: contract.requestId, result: 'changed' });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
const coverage = () => ({
|
||||||
|
expected: 59,
|
||||||
|
total: actions.length,
|
||||||
|
unique: new Set(actions.map(action => action.id)).size,
|
||||||
|
catalogued: actions.filter(action => action.label && action.kind).length,
|
||||||
|
selectorDeclared: actions.filter(action => action.selector).length,
|
||||||
|
nativeMetadata: actions.filter(action => action.mode === 'native').length,
|
||||||
|
contractMetadata: actions.filter(action => action.mode === 'contract').length,
|
||||||
|
nativeMapped: actions.filter(action => action.mode === 'native' && decoratedActionIds.has(action.id)).length,
|
||||||
|
dedicatedExecutor: explicitResultActionIds.size,
|
||||||
|
dedicatedExecutorDefinition: 'actions with explicit success/failure result recording',
|
||||||
|
reachable: actions.filter(action => action.implemented && action.selector).length,
|
||||||
|
mountedThisSession: decoratedActionIds.size,
|
||||||
|
decoratedInSession: decoratedActionIds.size,
|
||||||
|
notYetDecorated: actions.filter(action => !decoratedActionIds.has(action.id)).map(action => action.id),
|
||||||
|
implemented: actions.filter(action => action.implemented && action.selector).length,
|
||||||
|
unimplemented: actions.filter(action => !action.implemented || !action.selector).map(action => action.id),
|
||||||
|
selectorCollisions: Array.from(selectorCollisions),
|
||||||
|
apiExact: operations.filter(operation => operation.operationId && operation.method && operation.path).length,
|
||||||
|
uiOnlyExplained: actions.filter(action => action.operationIds.length === 0).length,
|
||||||
|
effectDeclared: actions.filter(action => action.effect).length,
|
||||||
|
mobileDeclared: actions.filter(action => action.mobile).length,
|
||||||
|
actuallyExecuted: new Set(trace.filter(entry => entry.phase === 'result' && entry.result === 'success').map(entry => entry.actionId)).size,
|
||||||
|
executionNote: 'catalogue and selector metadata are not counted as successful business execution',
|
||||||
|
navigation: { expected: 11, total: navEdges.length, unique: new Set(navEdges.map(edge => edge.id)).size },
|
||||||
|
pageBindings: { expected: 43, total: operations.length, unique: new Set(operations.map(operation => `${operation.pageId}:${operation.operationId}`)).size },
|
||||||
|
});
|
||||||
|
const assertions = {
|
||||||
|
pages: new Set(actions.map(action => action.pageId)).size === 12,
|
||||||
|
actionIds: actions.every(action => action.id.startsWith(`act.${action.pageId.toLowerCase()}.`)),
|
||||||
|
duplicateActions: new Set(actions.map(action => action.id)).size === actions.length,
|
||||||
|
operationContracts: operations.every(operation => /^(GET|POST|PUT|PATCH|DELETE)$/.test(operation.method) && operation.path.startsWith('/api/')),
|
||||||
|
navTargets: navEdges.every(edge => actions.some(action => action.pageId === edge.fromPageId) && actions.some(action => action.pageId === edge.toPageId)),
|
||||||
|
};
|
||||||
|
window.GONGXUE_PLATFORM_SPEC = { actions, operations, navEdges, supportingReads };
|
||||||
|
window.__GONGXUE_ACTION_CONTRACT__ = { coverage, assertions, trace };
|
||||||
|
window.decoratePlatformSpec = decorate;
|
||||||
|
window.recordPlatformSpecResult = (actionId, result = {}) => pushTrace({ phase: 'result', actionId, ...result });
|
||||||
|
window.__PLATFORM_CONTRACT_EVENTS__ = trace;
|
||||||
|
window.__PLATFORM_SPEC_COVERAGE__ = coverage();
|
||||||
|
})();
|
||||||
50
Tiku.Api/wwwroot/platform-admin/styles.css
Normal file
50
Tiku.Api/wwwroot/platform-admin/styles.css
Normal file
File diff suppressed because one or more lines are too long
@@ -3,12 +3,19 @@ using Microsoft.AspNetCore.DataProtection;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
using Tiku.Infrastructure.Bootstrap;
|
using Tiku.Infrastructure.Bootstrap;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
||||||
|
var isDevelopment = builder.Environment.IsDevelopment() ||
|
||||||
|
string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
|
||||||
|
Environments.Development,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringComparer.Ordinal);
|
var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringComparer.Ordinal);
|
||||||
PlatformAdminBootstrapOptions? bootstrapOptions = null;
|
PlatformAdminBootstrapOptions? bootstrapOptions = null;
|
||||||
if (bootstrapPlatformAdmin)
|
if (bootstrapPlatformAdmin)
|
||||||
@@ -22,11 +29,17 @@ if (bootstrapPlatformAdmin)
|
|||||||
var connectionString =
|
var connectionString =
|
||||||
builder.Configuration.GetConnectionString("Database") ??
|
builder.Configuration.GetConnectionString("Database") ??
|
||||||
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
||||||
throw new InvalidOperationException(
|
(isDevelopment
|
||||||
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
|
? $"Host=localhost;Database=tiku;Username={Environment.UserName}"
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
|
||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
builder.Services.AddInfrastructure(
|
||||||
|
connectionString,
|
||||||
|
isDevelopment && !bootstrapPlatformAdmin
|
||||||
|
? DevelopmentPlatformAdminSeeder.Configure
|
||||||
|
: null);
|
||||||
// Resolving UserManager<User> also activates Identity's default token providers.
|
// Resolving UserManager<User> also activates Identity's default token providers.
|
||||||
// Bootstrap never issues a reset token, so the migrator uses a process-local provider;
|
// Bootstrap never issues a reset token, so the migrator uses a process-local provider;
|
||||||
// the API remains the sole owner of the persisted, certificate-protected key ring.
|
// the API remains the sole owner of the persisted, certificate-protected key ring.
|
||||||
|
|||||||
192
Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs
Normal file
192
Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Npgsql;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Bootstrap;
|
||||||
|
|
||||||
|
public static class DevelopmentPlatformAdminSeeder
|
||||||
|
{
|
||||||
|
public const string Email = "admin@tiku.local";
|
||||||
|
public const string RoleCode = PlatformAdminBootstrapper.SuperAdminRoleCode;
|
||||||
|
|
||||||
|
public static void Configure(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
optionsBuilder
|
||||||
|
.UseSeeding((context, _) => Seed((TikuDbContext)context))
|
||||||
|
.UseAsyncSeeding((context, _, cancellationToken) =>
|
||||||
|
SeedAsync((TikuDbContext)context, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Seed(TikuDbContext dbContext)
|
||||||
|
{
|
||||||
|
ReloadPostgresTypes(dbContext);
|
||||||
|
|
||||||
|
if (dbContext.PlatformBackendUserRoles.Any())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var temporaryPassword = GenerateTemporaryPassword();
|
||||||
|
EnsureEmailIsAvailable(dbContext.Users.Any(user =>
|
||||||
|
user.NormalizedEmail == Email.ToUpperInvariant() ||
|
||||||
|
user.NormalizedUserName == Email.ToUpperInvariant()));
|
||||||
|
var permissionCodes = BackendPermissions.Platform.ToArray();
|
||||||
|
var existingPermissionCodes = dbContext.BackendPermissions
|
||||||
|
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||||
|
.Select(permission => permission.Code)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
|
||||||
|
dbContext.SaveChanges();
|
||||||
|
WriteFirstLoginInstructions(temporaryPassword);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> SeedAsync(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await ReloadPostgresTypesAsync(dbContext, cancellationToken);
|
||||||
|
|
||||||
|
if (await dbContext.PlatformBackendUserRoles.AnyAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var temporaryPassword = GenerateTemporaryPassword();
|
||||||
|
var normalizedEmail = Email.ToUpperInvariant();
|
||||||
|
EnsureEmailIsAvailable(await dbContext.Users.AnyAsync(user =>
|
||||||
|
user.NormalizedEmail == normalizedEmail ||
|
||||||
|
user.NormalizedUserName == normalizedEmail, cancellationToken));
|
||||||
|
var permissionCodes = BackendPermissions.Platform.ToArray();
|
||||||
|
var existingPermissionCodes = (await dbContext.BackendPermissions
|
||||||
|
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||||
|
.Select(permission => permission.Code)
|
||||||
|
.ToArrayAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
WriteFirstLoginInstructions(temporaryPassword);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSeedGraph(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
string temporaryPassword,
|
||||||
|
IReadOnlyCollection<string> permissionCodes,
|
||||||
|
IReadOnlySet<string> existingPermissionCodes)
|
||||||
|
{
|
||||||
|
var normalizedEmail = Email.ToUpperInvariant();
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Email = Email,
|
||||||
|
NormalizedEmail = normalizedEmail,
|
||||||
|
UserName = Email,
|
||||||
|
NormalizedUserName = normalizedEmail,
|
||||||
|
Name = "Local Platform Administrator",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
Status = UserStatus.Active,
|
||||||
|
ForcePasswordChange = true,
|
||||||
|
TwoFactorEnabled = false
|
||||||
|
};
|
||||||
|
var passwordHasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
|
||||||
|
{
|
||||||
|
IterationCount = 210_000
|
||||||
|
}));
|
||||||
|
user.PasswordHash = passwordHasher.HashPassword(user, temporaryPassword);
|
||||||
|
|
||||||
|
var role = new PlatformBackendRole
|
||||||
|
{
|
||||||
|
Code = RoleCode,
|
||||||
|
Name = "Platform Super Administrator",
|
||||||
|
Description = "Built-in Development administrator created by EF Core data seeding.",
|
||||||
|
Status = BackendRoleStatus.Active,
|
||||||
|
IsSystem = true
|
||||||
|
};
|
||||||
|
dbContext.Users.Add(user);
|
||||||
|
dbContext.PlatformBackendRoles.Add(role);
|
||||||
|
dbContext.BackendPermissions.AddRange(
|
||||||
|
permissionCodes
|
||||||
|
.Where(code => !existingPermissionCodes.Contains(code))
|
||||||
|
.Select(code => new BackendPermission
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = code,
|
||||||
|
Area = BackendPermissionArea.Platform,
|
||||||
|
Module = "platform",
|
||||||
|
Description = "Built-in platform permission.",
|
||||||
|
IsSystem = true
|
||||||
|
}));
|
||||||
|
dbContext.PlatformBackendRolePermissions.AddRange(
|
||||||
|
permissionCodes.Select(code => new PlatformBackendRolePermission
|
||||||
|
{
|
||||||
|
RoleId = role.Id,
|
||||||
|
PermissionCode = code
|
||||||
|
}));
|
||||||
|
dbContext.PlatformBackendUserRoles.Add(new PlatformBackendUserRole
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
RoleId = role.Id
|
||||||
|
});
|
||||||
|
dbContext.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
ActorUserId = user.Id,
|
||||||
|
Action = "platform.bootstrap_admin.created",
|
||||||
|
TargetType = "users",
|
||||||
|
TargetId = user.Id.ToString(),
|
||||||
|
Details = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
user.Email,
|
||||||
|
RoleCode,
|
||||||
|
ForcePasswordChange = true,
|
||||||
|
MfaEnrollmentRequired = true,
|
||||||
|
Source = "ef_core_use_seeding"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateTemporaryPassword() =>
|
||||||
|
$"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a";
|
||||||
|
|
||||||
|
private static void ReloadPostgresTypes(TikuDbContext dbContext)
|
||||||
|
{
|
||||||
|
if (dbContext.Database.IsNpgsql())
|
||||||
|
{
|
||||||
|
((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReloadPostgresTypesAsync(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (dbContext.Database.IsNpgsql())
|
||||||
|
{
|
||||||
|
await ((NpgsqlConnection)dbContext.Database.GetDbConnection())
|
||||||
|
.ReloadTypesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureEmailIsAvailable(bool isAssigned)
|
||||||
|
{
|
||||||
|
if (isAssigned)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot create the Development platform administrator because '{Email}' is already assigned.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteFirstLoginInstructions(string temporaryPassword)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Development platform administrator created by EF Core data seeding.");
|
||||||
|
Console.WriteLine($" Account: {Email}");
|
||||||
|
Console.WriteLine($" Temporary password: {temporaryPassword}");
|
||||||
|
Console.WriteLine(" Change the password and enroll TOTP MFA at first sign-in. This password is shown only once.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddInfrastructure(
|
public static IServiceCollection AddInfrastructure(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
string connectionString)
|
string connectionString,
|
||||||
|
Action<DbContextOptionsBuilder>? configureDatabase = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ public static class DependencyInjection
|
|||||||
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
||||||
options.UseNpgsql(dataSource, npgsql =>
|
options.UseNpgsql(dataSource, npgsql =>
|
||||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||||
|
configureDatabase?.Invoke(options);
|
||||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||||
});
|
});
|
||||||
services.AddIdentityCore<User>(options =>
|
services.AddIdentityCore<User>(options =>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class PlatformAdminStaticEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Platform_admin_static_shell_is_served_without_api_authentication()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/platform-admin/");
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Contains("恭学题库 SaaS · 平台管理", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("./platform-api.js", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("./platform-auth.js", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("platformAuthGate", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Platform_admin_short_path_redirects_to_static_shell_directory()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient(new()
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/platform-admin");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode);
|
||||||
|
Assert.Equal("/platform-admin/", response.Headers.Location?.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Platform_admin_runtime_uses_real_api_without_mock_fallback()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var runtime = await client.GetStringAsync("/platform-admin/runtime-config.js");
|
||||||
|
var authentication = await client.GetStringAsync("/platform-admin/platform-auth.js");
|
||||||
|
|
||||||
|
Assert.Contains("mode: 'api'", runtime, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("fallbackToMock: false", runtime, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("/api/auth/login/password", authentication, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("/api/auth/mfa/totp/confirm", authentication, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,43 @@ public sealed class PlatformAdminBootstrapperTests
|
|||||||
Assert.Equal("bootstrap_user_already_exists", exception.Code);
|
Assert.Equal("bootstrap_user_already_exists", exception.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Development_async_seed_creates_one_forced_enrollment_administrator()
|
||||||
|
{
|
||||||
|
await using var provider = CreateProvider();
|
||||||
|
await using var scope = provider.CreateAsyncScope();
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
|
||||||
|
var created = await DevelopmentPlatformAdminSeeder.SeedAsync(context);
|
||||||
|
var createdAgain = await DevelopmentPlatformAdminSeeder.SeedAsync(context);
|
||||||
|
|
||||||
|
Assert.True(created);
|
||||||
|
Assert.False(createdAgain);
|
||||||
|
var user = await context.Users.SingleAsync();
|
||||||
|
Assert.Equal(DevelopmentPlatformAdminSeeder.Email, user.Email);
|
||||||
|
Assert.True(user.ForcePasswordChange);
|
||||||
|
Assert.False(user.TwoFactorEnabled);
|
||||||
|
Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync());
|
||||||
|
Assert.Single(context.PlatformBackendUserRoles);
|
||||||
|
Assert.Single(context.AuditLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Development_sync_seed_is_idempotent()
|
||||||
|
{
|
||||||
|
await using var provider = CreateProvider();
|
||||||
|
await using var scope = provider.CreateAsyncScope();
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
|
||||||
|
var created = DevelopmentPlatformAdminSeeder.Seed(context);
|
||||||
|
var createdAgain = DevelopmentPlatformAdminSeeder.Seed(context);
|
||||||
|
|
||||||
|
Assert.True(created);
|
||||||
|
Assert.False(createdAgain);
|
||||||
|
Assert.Single(context.Users);
|
||||||
|
Assert.Single(context.PlatformBackendUserRoles);
|
||||||
|
}
|
||||||
|
|
||||||
private static ServiceProvider CreateProvider()
|
private static ServiceProvider CreateProvider()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
docs/quickstart.md
Normal file
138
docs/quickstart.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 本地开发快速开始
|
||||||
|
|
||||||
|
这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。
|
||||||
|
|
||||||
|
## 1. 准备环境
|
||||||
|
|
||||||
|
需要安装:
|
||||||
|
|
||||||
|
- .NET 10 SDK;
|
||||||
|
- PostgreSQL(当前本地开发已验证 PostgreSQL 18);
|
||||||
|
- `psql`、`createdb` 等 PostgreSQL 命令行工具。
|
||||||
|
|
||||||
|
确认工具可用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet --version
|
||||||
|
pg_isready -h 127.0.0.1 -p 5432
|
||||||
|
psql --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 获取并还原项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url> TIKU-BACKEND
|
||||||
|
cd TIKU-BACKEND
|
||||||
|
dotnet restore TIKU-BACKEND.slnx
|
||||||
|
dotnet build TIKU-BACKEND.slnx --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 创建本地数据库
|
||||||
|
|
||||||
|
如果本机 PostgreSQL 允许当前系统用户无密码登录,可以直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb -h 127.0.0.1 -U "$(whoami)" tiku
|
||||||
|
```
|
||||||
|
|
||||||
|
Development 环境未显式配置连接串时,API 和 DbMigrator 默认使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Host=localhost;Database=tiku;Username=<当前系统用户>
|
||||||
|
```
|
||||||
|
|
||||||
|
如果数据库用户名、端口或认证方式不同,通过环境变量传入连接串:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL='Host=127.0.0.1;Port=5432;Database=tiku;Username=<数据库用户>;Password=<本地密码>'
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把包含密码的连接串写进 README、`appsettings*.json` 或提交到 Git。团队成员应各自使用环境变量、.NET Secret Manager 或受控密钥存储。
|
||||||
|
|
||||||
|
## 4. 执行迁移并初始化管理员
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator
|
||||||
|
```
|
||||||
|
|
||||||
|
DbMigrator 会执行全部 EF Core Migration,并在全新 Development 数据库中自动创建平台超级管理员:
|
||||||
|
|
||||||
|
```text
|
||||||
|
账号:admin@tiku.local
|
||||||
|
密码:首次初始化时安全随机生成,只在当前终端输出一次
|
||||||
|
```
|
||||||
|
|
||||||
|
请立即保存终端显示的临时密码。重复执行 DbMigrator 是幂等的,不会重复创建管理员、重置密码或再次显示密码。
|
||||||
|
|
||||||
|
管理员首次登录后必须:
|
||||||
|
|
||||||
|
1. 修改临时密码;
|
||||||
|
2. 绑定 TOTP MFA;
|
||||||
|
3. 保存一次性恢复码。
|
||||||
|
|
||||||
|
如果数据库已经包含平台管理员,自动初始化会跳过。不要为了重新获取密码删除包含业务数据的数据库。
|
||||||
|
|
||||||
|
## 5. 启动 API 和平台后台
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project Tiku.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
默认开发地址:
|
||||||
|
|
||||||
|
- 平台后台:<http://localhost:5090/platform-admin/>
|
||||||
|
- Scalar API 文档:<http://localhost:5090/scalar/v1>
|
||||||
|
- OpenAPI JSON:<http://localhost:5090/openapi/v1.json>
|
||||||
|
- 健康检查:<http://localhost:5090/api/health>
|
||||||
|
|
||||||
|
平台后台默认连接同源真实 API,不会回退到 Mock 数据。当前开放的是已有后端契约的概览、租户、员工、审计和告警等页面;尚未接入真实接口的模块暂不开放。
|
||||||
|
|
||||||
|
## 6. 可选:启动 Worker
|
||||||
|
|
||||||
|
需要调试后台任务时,另开终端并使用相同数据库连接:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project Tiku.Worker
|
||||||
|
```
|
||||||
|
|
||||||
|
普通 API 开发不要求同时启动 Worker。
|
||||||
|
|
||||||
|
## 7. 开发前验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail http://localhost:5090/api/health
|
||||||
|
dotnet test TIKU-BACKEND.slnx --no-build
|
||||||
|
dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore
|
||||||
|
dotnet ef migrations has-pending-model-changes \
|
||||||
|
--project Tiku.Infrastructure \
|
||||||
|
--startup-project Tiku.DbMigrator \
|
||||||
|
--no-build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL 特有的 Migration、约束、事务和租户隔离行为必须使用真实 PostgreSQL 验证,不能只依赖 EF InMemory 测试。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 连接 PostgreSQL 失败
|
||||||
|
|
||||||
|
先检查服务和实际登录信息:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_isready -h 127.0.0.1 -p 5432
|
||||||
|
psql -h 127.0.0.1 -U <数据库用户> -d postgres -c 'select current_user;'
|
||||||
|
```
|
||||||
|
|
||||||
|
然后确认当前终端中的 `DATABASE_URL` 指向正确的主机、端口、数据库和用户。
|
||||||
|
|
||||||
|
### 首次迁移无法创建扩展
|
||||||
|
|
||||||
|
Migration 会创建 `citext` 和 `ltree` 扩展。初始化数据库的 PostgreSQL 用户必须有安装这些扩展所需的权限;请让本地数据库管理员预先安装扩展或授予对应权限。
|
||||||
|
|
||||||
|
### 没看到管理员临时密码
|
||||||
|
|
||||||
|
临时密码只在全新 Development 数据库首次创建管理员时显示。如果管理员绑定已经存在,迁移会安全跳过。请使用已有管理员账号的密码恢复流程,不要在源码或文档中添加固定密码。
|
||||||
|
|
||||||
|
### API 启动后出现 HTTPS 重定向警告
|
||||||
|
|
||||||
|
本地仅使用 HTTP profile 时可能看到无法确定 HTTPS 端口的警告,不影响 `http://localhost:5090` 的开发访问。需要验证 HTTPS 时使用项目的 `https` launch profile。
|
||||||
Reference in New Issue
Block a user