merge master into codex/wechat-mini-program-virtual-payment

This commit is contained in:
kdletters
2026-05-30 16:46:11 +08:00
119 changed files with 3345 additions and 428 deletions
@@ -95,6 +95,8 @@ npm run dev:admin-web
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
生产 `Genarrative-Stdb-Module-Publish` 的备份默认使用 `DATABASE_BACKUP_MODE=async`:流水线在 publish 前先生成本地冷备份,随后继续 publish,并把同一份发布前备份交给后台 Node 进程上传 OSS,避免低带宽 OSS 上传长时间占住部署窗口。需要强制在 publish 前等待打包和上传并让失败阻断发布时,手动选择 `DATABASE_BACKUP_MODE=sync`;已有其他备份窗口且明确接受风险时才选择 `skip`
查看本地 Rust/SpacetimeDB 日志:
```bash
+16
View File
@@ -23,6 +23,14 @@
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformErrorDialog.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## VectorEngine 图片生成 SendRequest 超时要按传输失败排查
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send``timeout=true``statusCode=null``errorSource=client error (SendRequest)`,前端只知道图片生成失败。
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id``metadata_json.userId/profileId` 定位触发者与草稿 / 作品;`request_send + timeout=true` 优先查请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502``429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## “我的”页每日任务卡不要硬编码进度
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
@@ -910,6 +918,14 @@
- 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。
- 关联:`src/services/assetReadUrlService.ts``src/components/ResolvedAssetImage.tsx``docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`
## 拼图图片生成失败后不要停在 ImageRefining
- 现象:拼图图片生成失败后,会话仍停留在 `PuzzleAgentStage::ImageRefining`,用户从作品架或生成页恢复时容易被当成生成中/精修中状态,重试入口和失败承接不清晰。
- 原因:`mark_puzzle_draft_generation_failed_tx` 只把 `PuzzleResultDraft.generation_status` 标成 `failed`,但 session stage 仍沿用旧的 `row.stage`;如果失败前已进入 `ImageRefining`,失败回写不会把会话带回结果草稿态。
- 处理:失败回写后按失败草稿重新解析 session stage:已发布保持 `Published`,仍满足发布门禁则为 `ReadyToPublish`,否则回到 `DraftReady`;前端生成页文案用“拼图图片生成进度 / 重新生成图片”,避免把失败态误导成还在生成整份草稿。
- 验证:运行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``npm run check:encoding`,以及拼图生成页恢复相关 `RpgEntryFlowShell.agent.interaction.test.tsx` 定向用例。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
## 本地短信登录页签突然消失
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
+24
View File
@@ -13,10 +13,13 @@ import type {
AdminOverviewResponse,
AdminTrackingEventListQuery,
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
AdminWorkVisibilityListResponse,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
@@ -194,6 +197,27 @@ export function upsertAdminCreationEntryConfig(
);
}
export function listAdminWorkVisibility(token: string) {
return request<AdminWorkVisibilityListResponse>(
'/admin/api/works/visibility',
{token},
);
}
export function updateAdminWorkVisibility(
token: string,
payload: AdminUpdateWorkVisibilityRequest,
) {
return request<AdminUpdateWorkVisibilityResponse>(
'/admin/api/works/visibility',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
+30
View File
@@ -177,6 +177,36 @@ export interface AdminUpsertCreationEntryTypeConfigRequest {
categorySortOrder: number;
}
export interface AdminWorkVisibilityEntryPayload {
sourceType: string;
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
title: string;
subtitle: string;
coverImageSrc?: string | null;
visible: boolean;
publishedAtMicros?: number | null;
updatedAtMicros: number;
}
export interface AdminWorkVisibilityListResponse {
entries: AdminWorkVisibilityEntryPayload[];
}
export interface AdminUpdateWorkVisibilityRequest {
sourceType: string;
profileId: string;
visible: boolean;
}
export interface AdminUpdateWorkVisibilityResponse {
entry: AdminWorkVisibilityEntryPayload;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
+7
View File
@@ -28,6 +28,7 @@ import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
@@ -205,6 +206,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'work-visibility' ? (
<AdminWorkVisibilityPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
+2
View File
@@ -3,6 +3,7 @@ import {
BadgeDollarSign,
LayoutDashboard,
LogOut,
Eye,
ShieldCheck,
ListChecks,
SlidersHorizontal,
@@ -35,6 +36,7 @@ const routeIcons = {
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
+3 -1
View File
@@ -7,7 +7,8 @@ export type AdminRouteId =
| 'invite'
| 'tasks'
| 'recharge-products'
| 'creation-entry';
| 'creation-entry'
| 'work-visibility';
export interface AdminRouteDefinition {
id: AdminRouteId;
@@ -25,6 +26,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
];
export function resolveAdminRoute(hash: string): AdminRouteId {
@@ -0,0 +1,269 @@
import {Eye, EyeOff, RefreshCcw} from 'lucide-react';
import {useEffect, useMemo, useState} from 'react';
import {
listAdminWorkVisibility,
updateAdminWorkVisibility,
} from '../api/adminApiClient';
import type {AdminWorkVisibilityEntryPayload} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminWorkVisibilityPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const sourceLabels: Record<string, string> = {
puzzle: '拼图',
'custom-world': '自定义世界',
'jump-hop': '跳一跳',
'wooden-fish': '敲木鱼',
match3d: '抓大鹅',
'square-hole': '方洞挑战',
'visual-novel': '视觉小说',
'big-fish': '大鱼吃小鱼',
'bark-battle': '汪汪声浪',
};
export function AdminWorkVisibilityPage({
token,
onUnauthorized,
}: AdminWorkVisibilityPageProps) {
const [entries, setEntries] = useState<AdminWorkVisibilityEntryPayload[]>([]);
const [keyword, setKeyword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [savingKey, setSavingKey] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshEntries();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const filteredEntries = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return entries;
}
return entries.filter((entry) =>
[
entry.sourceType,
sourceLabels[entry.sourceType] ?? '',
entry.title,
entry.subtitle,
entry.authorDisplayName,
entry.publicWorkCode,
entry.profileId,
entry.workId,
]
.join(' ')
.toLowerCase()
.includes(normalizedKeyword),
);
}, [entries, keyword]);
async function refreshEntries() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminWorkVisibility(token);
setEntries(sortEntries(response.entries));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleToggle(entry: AdminWorkVisibilityEntryPayload) {
const nextVisible = !entry.visible;
const target = entry.title.trim() || entry.publicWorkCode || entry.profileId;
const confirmed = await confirmWrite({
action: nextVisible ? '显示作品' : '隐藏作品',
target,
});
if (!confirmed) {
return;
}
const rowKey = buildEntryKey(entry);
setSavingKey(rowKey);
setErrorMessage('');
try {
const response = await updateAdminWorkVisibility(token, {
sourceType: entry.sourceType,
profileId: entry.profileId,
visible: nextVisible,
});
upsertEntry(response.entry);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setSavingKey('');
}
}
function upsertEntry(next: AdminWorkVisibilityEntryPayload) {
setEntries((current) =>
sortEntries([
...current.filter((entry) => buildEntryKey(entry) !== buildEntryKey(next)),
next,
]),
);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshEntries}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
<section className="admin-panel">
<label className="admin-field">
<span></span>
<input
placeholder="标题 / 作者 / 公开码 / profileId"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((entry) => {
const rowKey = buildEntryKey(entry);
const isSaving = savingKey === rowKey;
return (
<tr key={rowKey}>
<td>
<span className="admin-tag">
{sourceLabels[entry.sourceType] ?? entry.sourceType}
</span>
</td>
<td>
<strong>{entry.title || entry.profileId}</strong>
<small>{entry.subtitle || entry.profileId}</small>
</td>
<td>
{entry.authorDisplayName || '玩家'}
<small>{entry.ownerUserId}</small>
</td>
<td>
<span className="admin-table-cell-ellipsis">
{entry.publicWorkCode}
</span>
<small>{entry.profileId}</small>
</td>
<td>{formatMicros(entry.updatedAtMicros)}</td>
<td>
<span
className={
entry.visible
? 'admin-status admin-status-ok'
: 'admin-status admin-status-error'
}
>
{entry.visible ? '显示' : '隐藏'}
</span>
</td>
<td>
<button
className={
entry.visible
? 'admin-danger-button'
: 'admin-secondary-button'
}
disabled={isSaving}
type="button"
onClick={() => handleToggle(entry)}
>
{entry.visible ? (
<EyeOff size={16} aria-hidden="true" />
) : (
<Eye size={16} aria-hidden="true" />
)}
<span>
{isSaving
? '处理中'
: entry.visible
? '隐藏'
: '显示'}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && filteredEntries.length === 0 ? (
<div className="admin-empty-state"></div>
) : null}
</section>
{confirmDialog}
</section>
);
}
function sortEntries(entries: AdminWorkVisibilityEntryPayload[]) {
return [...entries].sort((left, right) => {
const timeCompare = right.updatedAtMicros - left.updatedAtMicros;
if (timeCompare !== 0) {
return timeCompare;
}
const sourceCompare = left.sourceType.localeCompare(right.sourceType);
if (sourceCompare !== 0) {
return sourceCompare;
}
return left.profileId.localeCompare(right.profileId);
});
}
function buildEntryKey(entry: AdminWorkVisibilityEntryPayload) {
return `${entry.sourceType}:${entry.profileId}`;
}
function formatMicros(value: number) {
if (!Number.isFinite(value)) {
return '-';
}
const date = new Date(Math.floor(value / 1000));
if (!Number.isFinite(date.getTime())) {
return '-';
}
return date.toLocaleString('zh-CN', {hour12: false});
}
@@ -0,0 +1,83 @@
# 作品可见性后台管理 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在后台增加统一作品可见性列表与修改能力,让管理员可以把已发布作品从公开 read model 中隐藏或恢复显示。
**Architecture:** 可见性仍以各玩法源表 `visible` 字段为真相源;新增 SpacetimeDB admin procedure 统一列出和更新各玩法作品可见性,`api-server` 只做鉴权、DTO 校验和 BFF 转发,后台前端新增简洁管理页。统一公开 read model 继续只消费 `visible=true` 的 source view,不向公开契约暴露后台字段。
**Tech Stack:** Rust server-rs + SpacetimeDB module/procedure + spacetime-client bindings/facade + shared-contracts DTO + React admin-web TypeScript。
---
### Task 1: 文档契约补齐
**Files:**
- Modify: `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
- Modify: `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
- [ ] 在 API 路由分组中补充 `/admin/api/works/visibility`
- [ ] 在统一公开作品 ReadModel 文档中写清后台只能修改源表 `visible`,隐藏后不进入 `public_work_gallery_entry` / `public_work_detail_entry`
### Task 2: DTO 与后端路由
**Files:**
- Modify: `server-rs/crates/shared-contracts/src/admin.rs`
- Modify: `server-rs/crates/api-server/src/admin.rs`
- Modify: `server-rs/crates/api-server/src/app.rs` 或现有 admin module router 文件
- [ ] 增加 `AdminWorkVisibilityEntryPayload``AdminWorkVisibilityListResponse``AdminUpdateWorkVisibilityRequest``AdminUpdateWorkVisibilityResponse`
- [ ] 新增 `GET /admin/api/works/visibility` handler,必须走 `require_admin_auth`
- [ ] 新增 `POST /admin/api/works/visibility` handler,校验 `sourceType``profileId` 非空并转发到 SpacetimeDB facade。
### Task 3: SpacetimeDB runtime/procedure 与 facade
**Files:**
- Modify: `server-rs/crates/module-runtime/src/domain.rs`
- Create: `server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs`
- Modify: `server-rs/crates/spacetime-module/src/runtime.rs`
- Modify: `server-rs/crates/spacetime-module/src/lib.rs`
- Modify: `server-rs/crates/spacetime-client/src/runtime.rs`
- Modify: `server-rs/crates/spacetime-client/src/mapper/runtime.rs`
- [ ] 增加 module-runtime typed input/output 类型。
- [ ] SpacetimeDB procedure 统一读取各玩法已发布源表/view,并返回可见性列表。
- [ ] SpacetimeDB procedure 根据 `sourceType + profileId` 修改对应源表 `visible``custom-world` 同步 `custom_world_gallery_entry.visible``big-fish` 使用 `session_id``bark-battle` 使用 `work_id`
- [ ] spacetime-client 增加 list/update facade 和 mapper。
### Task 4: 后台前端页面
**Files:**
- Modify: `apps/admin-web/src/api/adminApiTypes.ts`
- Modify: `apps/admin-web/src/api/adminApiClient.ts`
- Create: `apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx`
- Modify: `apps/admin-web/src/app/adminRoutes.ts`
- Modify: `apps/admin-web/src/app/AdminShell.tsx`
- Modify: `apps/admin-web/src/app/AdminApp.tsx`
- [ ] 增加 API 类型和 client 方法。
- [ ] 新增简洁表格页,显示玩法、标题、作者、公开码、更新时间、可见状态。
- [ ] 修改可见性时使用 `useAdminWriteConfirm` 确认。
- [ ] 接入后台导航和 route switch。
### Task 5: 生成绑定与验证
**Files:**
- Generated: `server-rs/crates/spacetime-client/src/module_bindings*`
- Generated: front-end shared bindings if generator updates them
- [ ] Run: `npm run spacetime:generate`
- [ ] Run: `npm run check:spacetime-schema`
- [ ] Run: `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`
- [ ] Run: `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- [ ] Run: `npm run admin-web:typecheck`
- [ ] Run: `npm run check:encoding`
### Task 6: 提交并推送
**Files:**
- All changed files
- [ ] Inspect `git diff` and `git status --short --branch`
- [ ] Commit with message `feat: add admin work visibility controls`
- [ ] Push current branch `codex/visible-work-field`
@@ -39,8 +39,21 @@
- `sort_time_micros`
- `detail_payload_json`
作品源表新增 `visible` 可见性字段,默认 `true``visible` 属于源表 / source view 过滤条件,不作为统一公开契约默认返回字段;当 `visible=false` 时,对应作品不得进入 `public_work_gallery_entry``public_work_detail_entry`
其中 `detail_payload_json` 只承载平台详情页展示扩展,不承载正式 runtime 配置、玩法规则或草稿真相。
## 后台可见性管理
后台通过独立接口管理已发布作品的源表可见性:
- `GET /admin/api/works/visibility`
- `POST /admin/api/works/visibility`
后台操作 key 使用统一的 `sourceType + profileId` 组合。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id``bark-battle` 对应 `work_id``custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。
该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry``public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。
## 来源与兼容
统一 public view 由现有玩法 source view 组装:
@@ -63,6 +76,8 @@
- 旧 view 保留,不删除。
- 旧 view 退到底层 source / 兼容职责。
-`public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。
- 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view``publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。
-`/api/runtime/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
@@ -51,7 +51,7 @@ npm run check:server-rs-ddd
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
- 健康检查:`GET /healthz`
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、兑换码、邀请码、任务配置和充值商品配置。
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
- 认证与账号:`/api/auth/*``/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。
- LLM 与语音:`/api/llm/*``/api/speech/volcengine/*`
@@ -70,6 +70,12 @@ npm run check:server-rs-ddd
需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。
### 认证态用户与会话摘要下发口径
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumberMasked``loginMethod``bindingStatus``wechatBound`
- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId``sessionIds``sessionCount``clientLabel``ipMasked``isCurrent``createdAt``lastSeenAt``expiresAt`
- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。
## api-server 模块化演进规则
`api-server` 的长期方向是从超大 `app.rs` 和超大 handler 文件收敛为按能力组织的 HTTP/BFF Module。后续改造必须保持 HTTP route、DTO、error envelope、SpacetimeDB schema、前端行为和计费语义默认不变;任何 contract 或 schema 变化都要先在当前文档中写清影响范围和迁移计划。
@@ -167,7 +173,7 @@ npm run check:server-rs-ddd
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d``api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event``event_key = external_generation_run``scope_kind = module``scope_id = provider``module_key = external-generation`。metadata 固定包含 `runId``provider``operation``requestLabel``requestPayload``status``success``failureReason``providerRequestId``resultPayload``startedAtMicros``completedAtMicros``durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
## SpacetimeDB 表目录
@@ -257,6 +263,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`BarkBattlePublishedConfigRow`
- 源码:`server-rs/crates/spacetime-module/src/bark_battle/tables.rs`
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `bark_battle_runtime_run`
@@ -293,6 +300,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`BigFishCreationSession`
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
- 索引:`by_big_fish_session_owner_user_id``by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `big_fish_event`
@@ -356,11 +364,13 @@ npm run check:server-rs-ddd
- Rust 结构体:`CustomWorldGalleryEntry`
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
- 作用:自定义世界公开 source 读模型。统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该投影并映射成跨玩法契约;`/api/runtime/custom-world-gallery` 保留旧 HTTP shape,并从统一 public cache 映射回旧 DTO。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `custom_world_profile`
- Rust 结构体:`CustomWorldProfile`
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `custom_world_session`
@@ -415,6 +425,7 @@ npm run check:server-rs-ddd
- 返回类型:`Vec<JumpHopGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs`
- 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `wooden_fish_agent_session`
@@ -450,6 +461,7 @@ npm run check:server-rs-ddd
- 返回类型:`Vec<WoodenFishGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs`
- 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `match3d_agent_message`
@@ -466,10 +478,12 @@ npm run check:server-rs-ddd
- Rust 结构体:`Match3DRuntimeRunRow`
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
### `match3d_work_profile`
### `match_3_d_work_profile`
- Rust 结构体:`Match3DWorkProfileRow`
- Rust accessor`match_3_d_work_profile`
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
- 兼容说明:dev 现有 SpacetimeDB 元数据中的真实表名 / 索引名为 `match_3_d_work_profile``match_3_d_work_profile_*_idx_btree`。module 内部 accessor 必须与该 canonical name 对齐,避免 Rust SDK 在 `index_id_from_name` 初始化二级索引时查找 `match3d_work_profile_*` 并触发 `No such index` panic。`migration.rs` 仍兼容旧迁移包中的 `match3d_work_profile` 表名补默认字段。
### SpacetimeDB view`match_3_d_gallery_view`
@@ -477,6 +491,7 @@ npm run check:server-rs-ddd
- 返回类型:`Vec<Match3DGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/match3d.rs`
- 说明:抓大鹅公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `npc_state`
@@ -663,6 +678,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED``GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。
统一公开作品 BFF 路由是 `GET /api/public-works``GET /api/public-works/{publicWorkCode}`,响应契约由 `shared-contracts::public_work``packages/shared/src/contracts/publicWork.ts` 共同维护。前端首期仍走 BFF HTTP,不直接订阅 SpacetimeDB;后续若允许浏览器直连订阅,也只能订阅 `public_work_gallery_entry` / `public_work_detail_entry` 这类稳定公开 read model,不能订阅 `puzzle_work_profile``custom_world_profile` 等源表后自行拼装列表。设计细节见 `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `quest_log`
@@ -715,6 +731,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
- 返回类型:`Vec<SquareHoleGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/square_hole.rs`
- 说明:方洞挑战公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `story_event`
@@ -790,3 +807,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
- 返回类型:`Vec<VisualNovelGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
- 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
@@ -203,7 +203,7 @@ UI 相关修改要重点验证:
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service
```
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。`Genarrative-Stdb-Module-Publish` 默认也会在 `spacetime publish` 前执行同一脚本;备份失败会阻断 publish,只有显式勾选 `SKIP_DATABASE_BACKUP`脚本参数 `--skip-backup` 才跳过。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive <tar.gz>` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL``/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
生产环境变量模板在 `deploy/env/api-server.env.example`
@@ -283,7 +283,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
- api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id``metadata_json.userId` / `metadata_json.profileId` 中记录触发者与草稿 / 作品作用域。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*``read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id``http.request.method``http.route``url.scheme``url.path``http.response.status_code``status_class``latency_ms``slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
@@ -342,7 +342,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt。常用查询:
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt、userId 和 profileId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,入口拿不到上下文时允许为空。常用查询:
```sql
SELECT event_id, scope_id AS provider, metadata_json, occurred_at
@@ -352,6 +352,25 @@ ORDER BY occurred_at DESC
LIMIT 50;
```
按失败阶段、触发者和作品作用域聚合时:
```sql
SELECT
json_extract(metadata_json, '$.failureStage') AS failure_stage,
user_id,
profile_id,
COUNT(*) AS failures,
MIN(occurred_at) AS first_seen,
MAX(occurred_at) AS last_seen
FROM tracking_event
WHERE event_key = 'external_api_call_failure'
GROUP BY failure_stage, user_id, profile_id
ORDER BY failures DESC, last_seen DESC
LIMIT 100;
```
VectorEngine `request_send``timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource = client error (SendRequest)` 是 Hyper 发送请求阶段的错误来源标签,不等于最终根因。若 `statusCode` 为空,应优先查同一时间窗口的 `api-server` request 日志、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502``429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
tracking outbox 默认配置:
```env
@@ -276,7 +276,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 创作 Tab 表单:填写作品标题、简介、主题 / 竞技背景描述、玩家形象描述、对手形象描述、拟声词和难度。拟声词支持换行、逗号、顿号、斜杠或竖线分隔;未手动编辑时随主题 / 形象描述自动重算,手动编辑后保持创作者自定义。
- 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON,返回包含 `draftId`、稳定 `workId``configVersion``rulesetVersion` 的草稿结果。
- 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端生成页 UI 和其它玩法保持同一圆环主视觉,`media/create_bg_video.mp4` 作为固定全屏页面背景层循环静音播放,主进度圆环居中展示总进度,只保留当前步骤名称和当前步骤进度,不再渲染三行槽位列表。视频层需要显式触发播放。三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character``opponent-character``ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。
- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置。
- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置;生成回写 `partial_failed` 时作品架不再显示整卡“生成中”遮罩,由结果页槽位错误承接失败
- 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets``/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。
- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。
- 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。
@@ -27,7 +27,7 @@ pipeline {
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致')
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户')
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布')
booleanParam(name: 'SKIP_DATABASE_BACKUP', defaultValue: false, description: '是否跳过 publish 前 OSS 数据库备份;默认不跳过,备份失败阻断发布')
choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: '数据库备份策略:async 在 publish 前生成本地冷备份、后台上传 OSS;sync 在 publish 前等待上传完成且失败阻断skip 跳过')
}
stages {
@@ -139,7 +139,22 @@ pipeline {
steps {
script {
def clearArg = params.CLEAR_DATABASE ? '--clear-database' : ''
def backupArg = params.SKIP_DATABASE_BACKUP ? '--skip-backup' : ''
def backupMode = params.DATABASE_BACKUP_MODE?.trim() ? params.DATABASE_BACKUP_MODE.trim() : 'async'
if (!(backupMode in ['async', 'sync', 'skip'])) {
error("DATABASE_BACKUP_MODE 只能是 async、sync 或 skip: ${backupMode}")
}
def publishScriptPath = 'scripts/deploy/production-stdb-publish.sh'
def publishScriptText = readFile(file: publishScriptPath, encoding: 'UTF-8')
def publishScriptSupportsBackupMode = publishScriptText.contains('--backup-mode')
def backupArg = ''
if (publishScriptSupportsBackupMode) {
backupArg = "--backup-mode \"${backupMode}\""
} else if (backupMode == 'sync') {
error('当前工作区的 scripts/deploy/production-stdb-publish.sh 还不支持 --backup-mode,无法保证 sync 语义。请先更新工作区脚本后再运行。')
} else {
echo "[Jenkins] 当前工作区脚本还不支持 --backup-modeasync/skip 自动降级为 --skip-backup,避免参数不兼容导致发布失败。"
backupArg = '--skip-backup'
}
def rootArg = "--root-dir \"${params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb'}\""
def runAsArg = params.SPACETIME_RUN_AS_USER?.trim()
? "--run-as-user \"${params.SPACETIME_RUN_AS_USER.trim()}\""
+1 -9
View File
@@ -4,19 +4,18 @@ export type AuthLoginMethod = 'password' | 'phone' | 'wechat';
export type AuthUser = {
id: string;
publicUserCode: string;
username: string;
displayName: string;
avatarUrl: string | null;
phoneNumberMasked: string | null;
loginMethod: AuthLoginMethod;
bindingStatus: AuthBindingStatus;
wechatBound: boolean;
createdAt: string;
};
export type PublicUserSummary = {
id: string;
publicUserCode: string;
username: string;
displayName: string;
avatarUrl: string | null;
};
@@ -159,14 +158,7 @@ export type AuthSessionSummary = {
sessionId: string;
sessionIds: string[];
sessionCount: number;
clientType: string;
clientRuntime: string;
clientPlatform: string;
clientLabel: string;
deviceDisplayName: string;
miniProgramAppId: string | null;
miniProgramEnv: string | null;
userAgent: string | null;
ipMasked: string | null;
isCurrent: boolean;
createdAt: string;
+28
View File
@@ -227,3 +227,31 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
return rawText.trim() || fallbackMessage;
}
export function appendApiErrorRequestId(
message: string,
requestId: string | null | undefined,
) {
const trimmedMessage = message.trim() || '请求失败';
const trimmedRequestId =
typeof requestId === 'string' && requestId.trim()
? requestId.trim()
: '';
if (!trimmedRequestId || trimmedMessage.includes(trimmedRequestId)) {
return trimmedMessage;
}
return `${trimmedMessage}requestId: ${trimmedRequestId}`;
}
export function parseApiErrorMessageWithRequestId(
rawText: string,
fallbackMessage: string,
requestId: string | null | undefined,
) {
return appendApiErrorRequestId(
parseApiErrorMessage(rawText, fallbackMessage),
requestId,
);
}
+124 -7
View File
@@ -20,10 +20,12 @@ const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
function usage() {
console.log(`用法:
npm run database:backup:oss -- [--data-dir <path>] [--work-dir <path>] [--bucket <bucket>] [--object-prefix <prefix>] [--keep-local]
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service]
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--defer-upload]
node scripts/database-backup-to-oss.mjs --upload-archive <path>
说明:
将 SpacetimeDB 数据目录打包成 .tar.gz,并上传到阿里云 OSS 指定 bucket。
--defer-upload 只生成本地冷备份和 manifest,不上传;后续用 --upload-archive 异步上传。
默认读取 .env / .env.local / .env.secrets.local;生产服务可传 --env-file /etc/genarrative/api-server.env。
shell 环境变量优先级最高,不会被 env 文件覆盖。
@@ -100,6 +102,11 @@ function parseArgs(argv) {
stopService: '',
database: '',
dryRun: false,
deferUpload: false,
uploadArchive: '',
manifestFile: '',
objectKey: '',
resultFile: '',
};
for (let index = 0; index < argv.length; index += 1) {
@@ -134,6 +141,9 @@ function parseArgs(argv) {
case '--object-prefix':
options.objectPrefix = readValue();
break;
case '--object-key':
options.objectKey = readValue();
break;
case '--access-key-id':
options.accessKeyId = readValue();
break;
@@ -155,6 +165,19 @@ function parseArgs(argv) {
case '--dry-run':
options.dryRun = true;
break;
case '--defer-upload':
options.deferUpload = true;
options.keepLocal = true;
break;
case '--upload-archive':
options.uploadArchive = readValue();
break;
case '--manifest-file':
options.manifestFile = readValue();
break;
case '--result-file':
options.resultFile = readValue();
break;
default:
throw new Error(`未知参数: ${arg}`);
}
@@ -260,6 +283,17 @@ function createArchive({dataDir, workDir, fileName}) {
return archivePath;
}
function writeManifest({manifestPath, payload}) {
writeFileSync(manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
function readManifest(manifestPath) {
if (!existsSync(manifestPath)) {
throw new Error(`备份清单不存在: ${manifestPath}`);
}
return JSON.parse(readFileSync(manifestPath, 'utf8'));
}
function hmac(key, content, encoding) {
return createHmac('sha256', key).update(content).digest(encoding);
}
@@ -372,6 +406,59 @@ async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKe
};
}
async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}) {
const archivePath = resolvePath(args.uploadArchive);
if (!existsSync(archivePath)) {
throw new Error(`待上传备份文件不存在: ${archivePath}`);
}
const manifestPath = resolvePath(args.manifestFile || `${archivePath}.manifest.json`);
const manifest = existsSync(manifestPath) ? readManifest(manifestPath) : {};
const dataDir = firstNonEmpty(manifest.dataDir, env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, DEFAULT_PRODUCTION_DATA_DIR);
const database = firstNonEmpty(args.database, manifest.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir));
const objectKey = firstNonEmpty(args.objectKey, manifest.objectKey, buildBackupNames({database, dataDir, objectPrefix}).objectKey);
console.log(`[database-backup] 上传已有备份: ${archivePath}`);
console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`);
if (args.dryRun) {
console.log('[database-backup] dry-run,仅校验上传配置。');
return;
}
const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret});
console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`);
const uploadedAt = new Date().toISOString();
writeManifest({
manifestPath,
payload: {
...manifest,
database,
bucket: result.bucket,
objectKey: result.objectKey,
contentLength: result.contentLength,
etag: result.etag,
uploadedAt,
uploadStatus: 'uploaded',
},
});
if (args.resultFile) {
writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, ...result, uploadedAt}, null, 2)}\n`, 'utf8');
}
const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true';
if (!keepLocal) {
rmSync(archivePath, {force: true});
rmSync(manifestPath, {force: true});
console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。');
} else {
console.log(`[database-backup] 已保留本地备份: ${archivePath}`);
console.log(`[database-backup] 已保留备份清单: ${manifestPath}`);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const env = loadEffectiveEnv(args.envFiles);
@@ -400,6 +487,11 @@ async function main() {
}
}
if (args.uploadArchive) {
await uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix});
return;
}
const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix});
console.log(`[database-backup] 数据目录: ${dataDir}`);
console.log(`[database-backup] 本地临时目录: ${workDir}`);
@@ -419,22 +511,47 @@ async function main() {
startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped);
}
const manifestPath = `${archivePath}.manifest.json`;
writeManifest({
manifestPath,
payload: {
createdAt: new Date().toISOString(),
database,
dataDir,
bucket,
objectKey,
archivePath,
uploadStatus: args.deferUpload ? 'deferred' : 'pending',
},
});
if (args.deferUpload) {
console.log(`[database-backup] 已生成本地冷备份,延后上传: ${archivePath}`);
console.log(`[database-backup] 已写入备份清单: ${manifestPath}`);
if (args.resultFile) {
writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, bucket, objectKey}, null, 2)}\n`, 'utf8');
}
return;
}
const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret});
console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`);
const manifestPath = `${archivePath}.manifest.json`;
writeFileSync(
writeManifest({
manifestPath,
`${JSON.stringify({
payload: {
createdAt: new Date().toISOString(),
database,
dataDir,
bucket: result.bucket,
objectKey: result.objectKey,
archivePath,
contentLength: result.contentLength,
etag: result.etag,
}, null, 2)}\n`,
'utf8',
);
uploadedAt: new Date().toISOString(),
uploadStatus: 'uploaded',
},
});
if (!keepLocal) {
rmSync(archivePath, {force: true});
+9
View File
@@ -331,6 +331,15 @@ mkdir -p "${RELEASE_DIR}"
cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server"
chmod +x "${RELEASE_DIR}/api-server"
SCRIPT_SOURCE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts"
mkdir -p "${RELEASE_DIR}/scripts"
if [[ -f "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" ]]; then
cp "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
else
echo "[production-api-deploy] 未找到数据库备份脚本,release 目录不会包含 scripts/database-backup-to-oss.mjs" >&2
fi
if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then
cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json"
fi
+129 -18
View File
@@ -5,14 +5,15 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--skip-backup]
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--backup-mode async|sync|skip]
说明:
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。
默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
publish 前默认执行一次 OSS 冷备份;备份失败会阻断 publish。仅明确传入 --skip-backup 时跳过
async 模式会在 publish 前先做本地冷备份,再在 publish 完成后后台上传 OSS,避免低带宽上传阻塞部署
如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。
失败时保留维护模式。
EOF
}
@@ -44,9 +45,15 @@ SERVER_URL="http://127.0.0.1:3101"
SPACETIME_ROOT_DIR="/stdb"
RUN_AS_USER="spacetimedb"
CLEAR_DATABASE=0
SKIP_BACKUP=0
BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}"
DEPLOY_COMPLETED=0
PUBLISH_TMP_DIR=""
ASYNC_BACKUP_STATUS_FILE=""
ASYNC_BACKUP_SCRIPT=""
ASYNC_BACKUP_ARCHIVE=""
ASYNC_BACKUP_MANIFEST=""
ASYNC_BACKUP_LOG=""
SPACETIME_READY_TIMEOUT_SECONDS="${GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS:-60}"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -84,9 +91,17 @@ while [[ $# -gt 0 ]]; do
shift
;;
--skip-backup)
SKIP_BACKUP=1
BACKUP_MODE="skip"
shift
;;
--sync-backup)
BACKUP_MODE="sync"
shift
;;
--backup-mode)
BACKUP_MODE="${2:?缺少 --backup-mode 的值}"
shift 2
;;
*)
echo "[production-stdb-publish] 未知参数: $1" >&2
usage >&2
@@ -104,11 +119,21 @@ if [[ ! "${SPACETIME_ROOT_DIR}" == /* || "${SPACETIME_ROOT_DIR}" == *".."* ]]; t
exit 1
fi
if [[ ! "${BACKUP_MODE}" =~ ^(async|sync|skip)$ ]]; then
echo "[production-stdb-publish] --backup-mode 只能是 async、sync 或 skip: ${BACKUP_MODE}" >&2
exit 1
fi
if [[ -n "${RUN_AS_USER}" && ! "${RUN_AS_USER}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
echo "[production-stdb-publish] --run-as-user 只能是本机用户名: ${RUN_AS_USER}" >&2
exit 1
fi
if [[ ! "${SPACETIME_READY_TIMEOUT_SECONDS}" =~ ^[0-9]+$ || "${SPACETIME_READY_TIMEOUT_SECONDS}" -le 0 ]]; then
echo "[production-stdb-publish] GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS 必须是正整数: ${SPACETIME_READY_TIMEOUT_SECONDS}" >&2
exit 1
fi
if [[ ! -d "${SOURCE_DIR}" ]]; then
echo "[production-stdb-publish] 发布目录不存在: ${SOURCE_DIR}" >&2
exit 1
@@ -123,6 +148,9 @@ fi
on_exit() {
local exit_code=$?
if [[ "${BACKUP_MODE}" == "async" && -n "${ASYNC_BACKUP_STATUS_FILE}" && -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
start_async_backup_upload || true
fi
if [[ -n "${PUBLISH_TMP_DIR}" && -d "${PUBLISH_TMP_DIR}" ]]; then
rm -rf "${PUBLISH_TMP_DIR}"
fi
@@ -134,27 +162,107 @@ on_exit() {
trap on_exit EXIT
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
if [[ "${SKIP_BACKUP}" -ne 1 ]]; then
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
prepare_async_backup() {
ASYNC_BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
ASYNC_BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少数据库备份脚本: ${ASYNC_BACKUP_SCRIPT}" >&2
exit 1
fi
echo "[production-stdb-publish] publish 前执行 OSS 冷备份"
node "${BACKUP_SCRIPT}" \
ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)"
echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS"
node "${ASYNC_BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--stop-service spacetimedb.service
else
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
fi
--stop-service spacetimedb.service \
--defer-upload \
--result-file "${ASYNC_BACKUP_STATUS_FILE}"
}
start_async_backup_upload() {
if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2
return 0
fi
ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then
echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2
return 0
fi
mkdir -p "$(dirname "${ASYNC_BACKUP_ARCHIVE}")"
ASYNC_BACKUP_LOG="$(dirname "${ASYNC_BACKUP_ARCHIVE}")/${DATABASE}-upload.log"
echo "[production-stdb-publish] 后台上传本地备份到 OSS: ${ASYNC_BACKUP_ARCHIVE}"
nohup node "${ASYNC_BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--upload-archive "${ASYNC_BACKUP_ARCHIVE}" \
--manifest-file "${ASYNC_BACKUP_MANIFEST}" \
>"${ASYNC_BACKUP_LOG}" 2>&1 &
echo "[production-stdb-publish] OSS 后台上传日志: ${ASYNC_BACKUP_LOG}"
rm -f "${ASYNC_BACKUP_STATUS_FILE}"
ASYNC_BACKUP_STATUS_FILE=""
}
wait_for_spacetime_ready() {
if [[ -z "${SERVER_URL}" ]]; then
echo "[production-stdb-publish] 使用 server alias=${SERVER_ALIAS},跳过 URL 健康检查等待"
return 0
fi
local ping_url="${SERVER_URL%/}/v1/ping"
local deadline=$((SECONDS + SPACETIME_READY_TIMEOUT_SECONDS))
local last_status=""
echo "[production-stdb-publish] 等待 SpacetimeDB 就绪: ${ping_url}timeout=${SPACETIME_READY_TIMEOUT_SECONDS}s"
while (( SECONDS < deadline )); do
# curl 失败时通常表示服务尚未监听;不立即失败,等待冷备份恢复后的 systemd 启动完成。
if last_status="$(curl -fsS --max-time 2 "${ping_url}" 2>&1)"; then
echo "[production-stdb-publish] SpacetimeDB 已就绪: ${ping_url}"
return 0
fi
sleep 2
done
echo "[production-stdb-publish] SpacetimeDB 未在超时内就绪: ${ping_url}" >&2
if [[ -n "${last_status}" ]]; then
echo "[production-stdb-publish] 最后一次健康检查输出: ${last_status}" >&2
fi
return 1
}
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
case "${BACKUP_MODE}" in
async)
prepare_async_backup
;;
sync)
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
exit 1
fi
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份,失败会阻断发布"
node "${BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--stop-service spacetimedb.service
;;
skip)
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
;;
esac
echo "[production-stdb-publish] 校验 wasm"
(
@@ -162,6 +270,8 @@ echo "[production-stdb-publish] 校验 wasm"
sha256sum -c spacetime_module.wasm.sha256
)
wait_for_spacetime_ready
PUBLISH_ARGS=(
--root-dir="${SPACETIME_ROOT_DIR}"
publish
@@ -218,4 +328,5 @@ fi
"${SCRIPT_DIR}/maintenance-off.sh"
DEPLOY_COMPLETED=1
echo "[production-stdb-publish] 完成"
+55 -1
View File
@@ -24,7 +24,9 @@ use shared_contracts::admin::{
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminWorkVisibilityListResponse,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -239,6 +241,40 @@ pub async fn admin_upsert_creation_entry_config(
))
}
pub async fn admin_list_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let admin_user_id = admin.session().subject.clone();
let entries = state
.list_admin_work_visibility(admin_user_id)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminWorkVisibilityListResponse { entries },
))
}
pub async fn admin_update_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpdateWorkVisibilityRequest>,
) -> Result<Json<Value>, AppError> {
let entry = validate_admin_work_visibility(payload)?;
let admin_user_id = admin.session().subject.clone();
let record = state
.update_admin_work_visibility(admin_user_id, entry.0, entry.1, entry.2)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminUpdateWorkVisibilityResponse { entry: record },
))
}
fn map_admin_creation_entry_type_config(
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
) -> AdminCreationEntryTypeConfigPayload {
@@ -284,6 +320,24 @@ fn validate_admin_creation_entry_config(
})
}
fn validate_admin_work_visibility(
payload: AdminUpdateWorkVisibilityRequest,
) -> Result<(String, String, bool), AppError> {
let source_type = payload.source_type.trim().to_string();
if source_type.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("sourceType 不能为空")
);
}
let profile_id = payload.profile_id.trim().to_string();
if profile_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("profileId 不能为空")
);
}
Ok((source_type, profile_id, payload.visible))
}
fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({
"provider": "spacetimedb",
+2 -1
View File
@@ -658,7 +658,8 @@ mod tests {
#[tokio::test]
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
let app =
build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
let response = app
.oneshot(
@@ -5,14 +5,12 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
AuthUserPayload {
id: user.id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
created_at: user.created_at,
}
}
@@ -20,6 +18,7 @@ pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPaylo
PublicUserSummaryPayload {
id: user.id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
}
@@ -185,14 +185,7 @@ fn build_session_summary(
session_id: representative.session_id.clone(),
session_ids,
session_count,
client_type: representative.client_info.client_type.clone(),
client_runtime: representative.client_info.client_runtime.clone(),
client_platform: representative.client_info.client_platform.clone(),
client_label,
device_display_name: representative.client_info.device_display_name.clone(),
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
mini_program_env: representative.client_info.mini_program_env.clone(),
user_agent: representative.client_info.user_agent.clone(),
ip_masked: mask_ip(representative.client_info.ip.as_deref()),
is_current,
created_at: group_earliest_created(&group).to_string(),
@@ -311,6 +311,7 @@ pub async fn generate_bark_battle_image_asset(
async {
generate_and_persist_bark_battle_image_asset(
&state,
&request_context,
&owner_user_id,
&slot,
draft_id.as_deref(),
@@ -1197,6 +1198,7 @@ fn bark_battle_sanitize_path_segment(value: &str, fallback: &str) -> String {
async fn generate_and_persist_bark_battle_image_asset(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
slot: &BarkBattleAssetSlot,
draft_id: Option<&str>,
@@ -1204,7 +1206,11 @@ async fn generate_and_persist_bark_battle_image_asset(
prompt: &str,
size: &str,
) -> Result<BarkBattleGeneratedImageAsset, AppError> {
let settings = require_openai_image_settings(state)?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
&request_context,
Some(owner_user_id.to_string()),
Some(draft_id.unwrap_or(asset_id).to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
@@ -94,7 +94,13 @@ pub async fn generate_character_visual(
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
let settings = require_openai_image_settings(&state)?;
let settings = require_openai_image_settings(&state)?
.with_external_api_audit_context(
&request_context,
Some(owner_user_id.clone()),
Some(character_id.clone()),
)
;
let http_client = build_openai_image_http_client(&settings)?;
state
@@ -318,7 +324,10 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&model,
&prompt,
)?;
let settings = require_openai_image_settings(state)?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
Some(owner_user_id.to_string()),
Some(character_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()

Some files were not shown because too many files have changed in this diff Show More