Merge branch 'codex/web-admin'
# Conflicts: # server-rs/crates/api-server/src/admin.rs
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
# 后台管理独立前端工程技术方案
|
||||
|
||||
日期:`2026-04-30`
|
||||
|
||||
对应 PRD:[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)
|
||||
|
||||
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染;所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`。
|
||||
|
||||
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接。
|
||||
|
||||
## 2. 工程结构
|
||||
|
||||
建议首版结构:
|
||||
|
||||
```text
|
||||
apps/
|
||||
└─ admin-web/
|
||||
├─ index.html
|
||||
├─ package.json
|
||||
├─ tsconfig.json
|
||||
├─ vite.config.ts
|
||||
└─ src/
|
||||
├─ main.tsx
|
||||
├─ app/
|
||||
│ ├─ AdminApp.tsx
|
||||
│ ├─ AdminShell.tsx
|
||||
│ └─ adminRoutes.ts
|
||||
├─ api/
|
||||
│ ├─ adminApiClient.ts
|
||||
│ └─ adminApiTypes.ts
|
||||
├─ auth/
|
||||
│ └─ adminAuthStore.ts
|
||||
├─ pages/
|
||||
│ ├─ AdminLoginPage.tsx
|
||||
│ ├─ AdminOverviewPage.tsx
|
||||
│ ├─ AdminDebugHttpPage.tsx
|
||||
│ ├─ AdminRedeemCodePage.tsx
|
||||
│ └─ AdminInviteCodePage.tsx
|
||||
└─ styles/
|
||||
└─ admin.css
|
||||
```
|
||||
|
||||
首版可使用独立 `package.json`,不要求立刻把根工程改成 npm workspace。后续如果根工程统一 workspace,再把 `apps/admin-web` 纳入统一脚本。
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
1. React + TypeScript + Vite。
|
||||
2. 图标使用 `lucide-react`。
|
||||
3. 样式首版使用普通 CSS 或 CSS Modules,不引入新的大型 UI 组件库。
|
||||
4. 请求使用浏览器 `fetch` 封装,不新增状态管理库。
|
||||
5. 不引入 SpacetimeDB TypeScript SDK;管理端不直连 SpacetimeDB。
|
||||
|
||||
## 4. API 边界
|
||||
|
||||
### 4.1 基础约定
|
||||
|
||||
所有管理端请求使用同一个 `adminApiClient`:
|
||||
|
||||
1. base URL 由 `VITE_ADMIN_API_BASE_URL` 配置。
|
||||
2. 未配置时默认同源空前缀。
|
||||
3. 有 token 时附加 `Authorization: Bearer <token>`。
|
||||
4. 后端统一响应 envelope 时,前端读取 `data`;错误优先读取 `error.details.message`,再读 `error.message`,最后回退到 HTTP 状态。
|
||||
|
||||
前端统一按以下响应形状解析,不在页面组件里重复拆 envelope:
|
||||
|
||||
```ts
|
||||
export interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
meta?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: {
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
meta?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
`adminApiClient` 暴露 `request<T>()`、`get<T>()`、`post<T>()` 三层即可。页面只拿到成功数据或抛出的中文错误消息,不直接处理 `Response`。
|
||||
|
||||
### 4.2 已有管理接口
|
||||
|
||||
| 功能 | 方法与路径 | 鉴权 |
|
||||
| --- | --- | --- |
|
||||
| 管理员登录 | `POST /admin/api/login` | 无 |
|
||||
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
|
||||
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
|
||||
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
|
||||
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
|
||||
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
|
||||
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
|
||||
|
||||
### 4.3 前端类型命名
|
||||
|
||||
后台前端首版不引入自动生成 contract。为了避免字段漂移,`apps/admin-web/src/api/adminApiTypes.ts` 必须按 `shared-contracts` 的 camelCase JSON 字段命名:
|
||||
|
||||
```ts
|
||||
export interface AdminSessionPayload {
|
||||
subject: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
token: string;
|
||||
admin: AdminSessionPayload;
|
||||
}
|
||||
|
||||
export interface AdminOverviewResponse {
|
||||
service: AdminServiceOverviewPayload;
|
||||
database: AdminDatabaseOverviewPayload;
|
||||
}
|
||||
|
||||
export interface AdminServiceOverviewPayload {
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
jwtIssuer: string;
|
||||
adminEnabled: boolean;
|
||||
spacetimeServerUrl: string;
|
||||
spacetimeDatabase: string;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseOverviewPayload {
|
||||
databaseIdentity: string | null;
|
||||
ownerIdentity: string | null;
|
||||
hostType: string | null;
|
||||
schemaTableNames: string[];
|
||||
tableStats: AdminDatabaseTableStatPayload[];
|
||||
fetchErrors: string[];
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableStatPayload {
|
||||
tableName: string;
|
||||
rowCount: number | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface AdminDebugHeaderInput {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AdminDebugHttpRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: AdminDebugHeaderInput[];
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface AdminDebugHttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: AdminDebugHeaderInput[];
|
||||
bodyText: string;
|
||||
bodyJson: unknown | null;
|
||||
}
|
||||
```
|
||||
|
||||
兑换码类型同样保持 camelCase:
|
||||
|
||||
```ts
|
||||
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
rewardPoints: number;
|
||||
maxUses: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
allowedPublicUserCodes: string[];
|
||||
}
|
||||
|
||||
export interface AdminDisableProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileInviteCodeRequest {
|
||||
inviteCode: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProfileRedeemCodeAdminResponse {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
rewardPoints: number;
|
||||
maxUses: number;
|
||||
globalUsedCount: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileInviteCodeAdminResponse {
|
||||
userId: string;
|
||||
inviteCode: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 登录 contract
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "root",
|
||||
"password": "secret123"
|
||||
}
|
||||
```
|
||||
|
||||
成功数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<admin bearer token>",
|
||||
"admin": {
|
||||
"subject": "admin:root",
|
||||
"username": "root",
|
||||
"displayName": "root",
|
||||
"roles": ["admin"],
|
||||
"issuedAt": "2026-04-30T00:00:00Z",
|
||||
"expiresAt": "2026-04-30T04:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`503` 表示后台未启用;`401` 表示用户名或密码错误。
|
||||
|
||||
### 4.5 总览 contract
|
||||
|
||||
`GET /admin/api/overview` 返回:
|
||||
|
||||
1. `service`:`bindHost`、`bindPort`、`jwtIssuer`、`adminEnabled`、`spacetimeServerUrl`、`spacetimeDatabase`。
|
||||
2. `database`:`databaseIdentity`、`ownerIdentity`、`hostType`、`schemaTableNames`、`tableStats`、`fetchErrors`。
|
||||
|
||||
后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。
|
||||
|
||||
后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。
|
||||
|
||||
`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。
|
||||
|
||||
### 4.6 API 调试 contract
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/healthz",
|
||||
"headers": [],
|
||||
"body": ""
|
||||
}
|
||||
```
|
||||
|
||||
限制由后端执行:
|
||||
|
||||
1. `path` 只允许同源相对路径。
|
||||
2. 禁止绝对 URL。
|
||||
3. 禁止调试 `/admin/api/login`。
|
||||
4. 禁止覆盖危险请求头。
|
||||
5. 请求体大小和超时由后端收口。
|
||||
|
||||
### 4.7 兑换码管理 contract
|
||||
|
||||
创建/更新请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "WELCOME2026",
|
||||
"mode": "public",
|
||||
"rewardPoints": 100,
|
||||
"maxUses": 1,
|
||||
"enabled": true,
|
||||
"allowedUserIds": [],
|
||||
"allowedPublicUserCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
停用请求:
|
||||
|
||||
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "WELCOME2026"
|
||||
}
|
||||
```
|
||||
|
||||
成功返回兑换码记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "WELCOME2026",
|
||||
"mode": "public",
|
||||
"rewardPoints": 100,
|
||||
"maxUses": 1,
|
||||
"globalUsedCount": 0,
|
||||
"enabled": true,
|
||||
"allowedUserIds": [],
|
||||
"createdBy": "admin:root",
|
||||
"createdAt": "2026-04-30T00:00:00Z",
|
||||
"updatedAt": "2026-04-30T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
|
||||
|
||||
### 4.8 邀请码管理 contract
|
||||
|
||||
创建/更新请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"inviteCode": "SPRING2026",
|
||||
"metadata": {
|
||||
"batch": "spring"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
成功返回邀请码记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "admin",
|
||||
"inviteCode": "SPRING2026",
|
||||
"metadata": {
|
||||
"batch": "spring"
|
||||
},
|
||||
"createdAt": "2026-04-30T00:00:00Z",
|
||||
"updatedAt": "2026-04-30T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。
|
||||
|
||||
## 5. 鉴权与会话
|
||||
|
||||
1. token key 固定为 `genarrative_admin_token`。
|
||||
2. token 首版存 localStorage。
|
||||
3. 应用启动时如果存在 token,先调用 `GET /admin/api/me`。
|
||||
4. `401` 时清空 token 并回到登录页。
|
||||
5. `403` 时展示无权限状态,不自动重试。
|
||||
6. 退出登录只清理本地 token;首版没有后台 refresh session 和服务端会话吊销。
|
||||
|
||||
## 6. 页面实现要点
|
||||
|
||||
1. `AdminShell` 承载导航、当前管理员、退出按钮和页面容器。
|
||||
2. 登录页不进入 `AdminShell`,避免未登录时展示后台导航。
|
||||
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`。
|
||||
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`。
|
||||
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
|
||||
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata,不在前端复制后端邀请码规则。
|
||||
7. 所有按钮的 loading 状态必须锁定重复提交。
|
||||
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
|
||||
|
||||
## 7. 部署与联调
|
||||
|
||||
### 7.1 本地联调
|
||||
|
||||
1. 启动后端:`npm run api-server:maincloud`。
|
||||
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`。
|
||||
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到 `ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`。
|
||||
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server。
|
||||
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口。
|
||||
|
||||
### 7.2 构建部署
|
||||
|
||||
首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择:
|
||||
|
||||
1. 独立静态站点域名,例如 `https://admin.example.com`。
|
||||
2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标。
|
||||
|
||||
无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。
|
||||
|
||||
### 7.3 后台工程脚本
|
||||
|
||||
`apps/admin-web/package.json` 首版至少提供以下脚本:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果后续接入根 npm workspace,再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。
|
||||
|
||||
当前根工程同步提供以下转发脚本:
|
||||
|
||||
1. `npm run admin-web:dev`
|
||||
2. `npm run admin-web:typecheck`
|
||||
3. `npm run admin-web:build`
|
||||
4. `npm run admin-web:preview`
|
||||
|
||||
## 8. 测试计划
|
||||
|
||||
1. `apps/admin-web`:
|
||||
- 登录成功和失败。
|
||||
- token 恢复、过期清理、退出登录。
|
||||
- 总览页正常数据、部分表统计失败、整体请求失败。
|
||||
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
|
||||
- 兑换码 public/unique/private 表单提交和停用。
|
||||
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
|
||||
2. 根工程:
|
||||
- `npm run check:encoding`。
|
||||
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
|
||||
3. 后端:
|
||||
- 继续保留 `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`。
|
||||
- 修改后端管理 API 后必须运行 `npm run api-server:maincloud` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。
|
||||
|
||||
## 9. 后续扩展边界
|
||||
|
||||
后续新增用户管理、作品审核、资产审核、订单/充值管理时,必须先补对应 PRD 和技术方案,并在 `server-rs` 增加受保护管理 API。不要让 `apps/admin-web` 直接读取 SpacetimeDB 或复制业务规则。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
1. 先创建 `apps/admin-web` 工程骨架,确保空应用可 `dev/build`。
|
||||
2. 再实现 `adminApiTypes` 与 `adminApiClient`,用 `/admin/api/login` 做第一条真实链路。
|
||||
3. 接入 `adminAuthStore` 和启动恢复逻辑,确认 `401` 会清理本地 token。
|
||||
4. 完成 `AdminShell` 与四页路由,再分别接入总览、API 调试、兑换码和邀请码接口。
|
||||
5. 最后补测试、运行 `npm run check:encoding`,并确认 `GET /admin` 仍由 `api-server` 返回 `404`。
|
||||
|
||||
当前实现已完成第 1 至第 4 步。验证以实际命令输出为准。
|
||||
Reference in New Issue
Block a user