Merge branch 'codex/feature-1'
# Conflicts: # docs/【玩法创作】平台入口与玩法链路-2026-05-15.md # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02 底部加号创作入口页 banner 与最近创作口径
|
||||
|
||||
- 背景:创作入口页 banner 曾固定为前端两张主题赛卡,且模板分类兜底会产生 `recent` / `最近创作` 页签,和后台配置及真实作品数据口径冲突。
|
||||
- 决策:点击底部加号进入的创作入口页 banner 改由后端 `eventBanners` 数组配置,多条自动轮播;旧 `eventBanner` 只保留单条兼容。后台公告配置使用表单维护标题与 HTML 内容,保存时序列化为后端 `eventBannersJson` 传输字段;HTML 只允许经空权限 iframe 展示,不执行 JSX 或直接 DOM 注入。`最近创作` 不再作为模板分类,只由真实草稿 / 作品架后端数据决定是否展示,生成失败草稿也必须进入;模板分类缺失或历史 `recent` 统一归一到 `recommended` / `热门推荐`。移动端草稿页作品卡禁止长按选择文字,但输入框和可编辑区域保留选择能力。
|
||||
- 影响范围:`server-rs/crates/module-runtime`、`server-rs/crates/spacetime-module`、`server-rs/crates/spacetime-client`、`server-rs/crates/api-server`、`shared-contracts`、`src/components/custom-world-home`、`src/components/platform-entry`、`apps/admin-web`、`src/index.css`。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、相关 Rust / Vitest 入口配置测试和浏览器点击底部加号截图。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-05-26 微信小程序充值全面接入虚拟支付
|
||||
|
||||
- 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。
|
||||
@@ -164,10 +172,10 @@
|
||||
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-24 创作 Tab banner 轮播只展示主题赛
|
||||
## 2026-05-24 创作入口页 banner 曾固定主题赛
|
||||
|
||||
- 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。
|
||||
- 决策:创作 Tab 首屏 banner 轮播只展示 `拼图主题创作赛` 与 `抓大鹅主题创作赛` 两张主题卡;后端返回的 `eventBanner` 仅作为开始时间、结束时间等公共字段来源,不再直接作为一张轮播卡渲染。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。
|
||||
- 背景:点击底部加号进入的创作入口页 banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致出现 58000 奖池活动卡,和当时只强调拼图 / 抓大鹅主题赛的产品口径不一致。
|
||||
- 决策:当时固定只展示 `拼图主题创作赛` 与 `抓大鹅主题创作赛` 两张主题卡;该口径已被 2026-06-02 的后台 `eventBanners` 配置决策替代。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。
|
||||
- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
- 验证方式:`CustomWorldCreationHub.test.tsx` 应断言默认活动标题不出现在 start-only 创作页,且 `creation-event-banner__timebar` 位于 `creation-event-banner__pager` 前。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
@@ -191,7 +199,7 @@
|
||||
## 2026-05-24 创作 Tab 模板卡点击直达已有玩法入口表单
|
||||
|
||||
- 背景:创作 Tab 首屏需要对齐参考图,展示赛事 banner、玩法模板分类和两列模板卡;点击模板卡时,空白入口页会让用户多走一层,占位感也会让人误以为功能未接好。
|
||||
- 决策:`/creation/<play>` 直达对应玩法已有的入口创作表单 stage,不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台,继续承接草稿恢复和后续编排。创作 Tab 首屏 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。
|
||||
- 决策:`/creation/<play>` 直达对应玩法已有的入口创作表单 stage,不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台,继续承接草稿恢复和后续编排。点击底部加号进入的创作入口页 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。
|
||||
- 影响范围:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、创作大厅交互测试与平台入口文档。
|
||||
- 验证方式:`npm test -- src/routing/appPageRoutes.test.ts`、`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card\"`、`npm run typecheck`、`npm run check:encoding` 通过;创作卡片点击后应进入对应工作台,不再出现空白入口页。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
@@ -243,7 +243,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- 移动端优先,再兼容网页端。
|
||||
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
|
||||
- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。
|
||||
- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。底部加号创作入口页公告位也跟随后端 `eventBanners` 配置,前端只做展示和轮播;后台公告用表单维护标题与 HTML 内容,保存时再序列化为后端 `eventBannersJson` 传输字段。`最近创作` 不属于模板分类,不能作为分类缺失兜底;生成中和生成失败的真实草稿摘要都应进入最近创作。
|
||||
- 一期统一创作页字段 spec 同样跟随 `GET /api/creation-entry/config`,由 `creationTypes[].unifiedCreationSpec` 下发;拼图、抓大鹅、敲木鱼之外的模板不接入该扩展位,前端只保留旧后端缺字段时的兜底默认。
|
||||
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
|
||||
- 不在 UI 中默认写功能说明类文本。
|
||||
|
||||
@@ -107,10 +107,25 @@
|
||||
## 玩法入口分类字段缺失要前端兜底
|
||||
|
||||
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
||||
- 处理:`normalizeCategoryId(...)` 和 `normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recent` / `最近创作`。前端这里是展示派生层,不能要求所有历史配置都先补齐字段。
|
||||
- 处理:`normalizeCategoryId(...)` 和 `normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recommended` / `热门推荐`;历史 `recent` / `最近创作` 也要归一到推荐分类。`最近创作` 不属于模板分类页签,只能由真实草稿 / 作品架后端数据决定是否展示。
|
||||
- 验证:`npm test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`,再打开本地创作页确认能正常进入创作 Tab。
|
||||
- 关联:`src/components/platform-entry/platformEntryCreationTypes.ts`、`src/components/platform-entry/platformEntryCreationTypes.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 创作入口公告不要恢复前端固定两卡
|
||||
|
||||
- 现象:点击底部加号进入的创作入口页只展示固定的拼图 / 抓大鹅主题卡,后台改公告表单后前台没有变化。
|
||||
- 原因:前端重新硬编码 banner 列表,绕过了 `GET /api/creation-entry/config` 的 `eventBanners` 配置。
|
||||
- 处理:创作入口页公告位优先读取后端 `eventBanners` 数组,多条自动轮播;旧 `eventBanner` 只做单条兼容兜底。后台主格式是标题与 HTML 内容表单,保存时序列化为后端 `eventBannersJson` 传输字段,只允许受控 HTML 片段经空权限 iframe 展示,不执行 JSX 或直接 DOM 注入。
|
||||
- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。
|
||||
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`server-rs/crates/module-runtime/src/application.rs`、`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`。
|
||||
|
||||
## 移动端草稿卡不要长按选中文字
|
||||
|
||||
- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。
|
||||
- 处理:移动端只对 `#platform-tab-panel-saves .creation-work-card` 禁止 `user-select` 和 `-webkit-touch-callout`;输入框、文本域和 `[contenteditable='true']` 保留文本选择能力,避免破坏真实编辑场景。
|
||||
- 验证:移动端草稿页长按普通作品卡文字不出现系统选区;`src/index.test.ts` 应覆盖 CSS 选择器和可编辑控件例外。
|
||||
- 关联:`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 草稿页未读点不要继续用红色 literal
|
||||
|
||||
- 现象:草稿页底部 Tab 和作品架的未读点视觉上仍像红点,或 glow 仍带红色阴影,和平台暖棕体系不一致。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDebugHttpRequest,
|
||||
@@ -197,6 +198,21 @@ export function upsertAdminCreationEntryConfig(
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存创作入口公告表单序列化后的后端传输字段。 */
|
||||
export function upsertAdminCreationEntryBanners(
|
||||
token: string,
|
||||
payload: AdminUpsertCreationEntryEventBannersRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config/banners',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminWorkVisibility(token: string) {
|
||||
return request<AdminWorkVisibilityListResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
|
||||
@@ -144,10 +144,25 @@ export interface AdminTrackingEventListQuery {
|
||||
}
|
||||
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
eventBanners: AdminCreationEntryEventBannerPayload[];
|
||||
}
|
||||
|
||||
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
|
||||
export interface AdminCreationEntryEventBannerPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
renderMode: 'structured' | 'html';
|
||||
htmlCode?: string | null;
|
||||
}
|
||||
|
||||
/** 后台单个创作模板入口配置,公告不再绑定在某一个入口上。 */
|
||||
export interface AdminCreationEntryTypeConfigPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -164,6 +179,7 @@ export interface AdminCreationEntryTypeConfigPayload {
|
||||
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
|
||||
}
|
||||
|
||||
/** 后台保存创作模板入口开关与统一创作契约的请求体。 */
|
||||
export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -179,6 +195,13 @@ export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
|
||||
}
|
||||
|
||||
/** 后台保存创作入口公告表单序列化结果的请求体。 */
|
||||
export interface AdminUpsertCreationEntryEventBannersRequest {
|
||||
/** 传输字段沿用后端契约,内容由后台表单生成。 */
|
||||
eventBannersJson: string;
|
||||
}
|
||||
|
||||
/** 后台统一创作工作台契约表单的传输结构。 */
|
||||
export interface UnifiedCreationSpecPayload {
|
||||
playId: string;
|
||||
title: string;
|
||||
@@ -188,6 +211,7 @@ export interface UnifiedCreationSpecPayload {
|
||||
fields: UnifiedCreationFieldPayload[];
|
||||
}
|
||||
|
||||
/** 后台统一创作字段契约,保存前会校验字段类型和必填标记。 */
|
||||
export interface UnifiedCreationFieldPayload {
|
||||
id: string;
|
||||
kind: 'text' | 'select' | 'image' | 'audio';
|
||||
|
||||
@@ -200,6 +200,13 @@ export function AdminApp() {
|
||||
onResultChange={setInviteResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'creation-announcement' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'creation-entry' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
token={token}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BadgeDollarSign,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
@@ -35,6 +36,7 @@ const routeIcons = {
|
||||
invite: TicketCheck,
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'creation-announcement': Megaphone,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
'work-visibility': Eye,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {expect, test} from 'vitest';
|
||||
|
||||
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
|
||||
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
|
||||
test('后台入口公告路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'creation-announcement',
|
||||
label: '入口公告',
|
||||
hash: '#creation-announcement',
|
||||
});
|
||||
expect(resolveAdminRoute('#creation-announcement')).toBe(
|
||||
'creation-announcement',
|
||||
);
|
||||
expect(routeHash('creation-announcement')).toBe('#creation-announcement');
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
|
||||
export type AdminRouteId =
|
||||
| 'overview'
|
||||
| 'tables'
|
||||
@@ -7,9 +8,11 @@ export type AdminRouteId =
|
||||
| 'invite'
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'creation-announcement'
|
||||
| 'creation-entry'
|
||||
| 'work-visibility';
|
||||
|
||||
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
label: string;
|
||||
@@ -25,10 +28,12 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
];
|
||||
|
||||
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
|
||||
return (
|
||||
@@ -37,6 +42,7 @@ export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据后台路由标识反查 hash,供导航点击时同步地址栏。 */
|
||||
export function routeHash(routeId: AdminRouteId) {
|
||||
return (
|
||||
adminRoutes.find((route) => route.id === routeId)?.hash ??
|
||||
|
||||
@@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
upsertAdminCreationEntryBanners,
|
||||
upsertAdminCreationEntryConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
),
|
||||
getAdminCreationEntryConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminCreationEntryBanners: vi.fn(),
|
||||
upsertAdminCreationEntryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -40,6 +42,18 @@ const puzzleSpec: UnifiedCreationSpecPayload = {
|
||||
};
|
||||
|
||||
const configResponse: AdminCreationEntryConfigResponse = {
|
||||
eventBanners: [
|
||||
{
|
||||
title: '创作公告',
|
||||
description: '',
|
||||
coverImageSrc: '',
|
||||
prizePoolMudPoints: 0,
|
||||
startsAtText: '',
|
||||
endsAtText: '',
|
||||
renderMode: 'html',
|
||||
htmlCode: '<section>后台公告</section>',
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
@@ -50,9 +64,9 @@ const configResponse: AdminCreationEntryConfigResponse = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: puzzleSpec,
|
||||
},
|
||||
@@ -62,6 +76,7 @@ const configResponse: AdminCreationEntryConfigResponse = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
});
|
||||
|
||||
@@ -93,7 +108,10 @@ test('创作入口后台展示并保存统一创作契约', async () => {
|
||||
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = await screen.findByLabelText('契约 JSON');
|
||||
@@ -110,3 +128,109 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async ()
|
||||
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('创作入口后台用表单保存公告配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2);
|
||||
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: {value: '周末创作赛'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: {value: '<section>新的入口公告</section>'},
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '新增公告'}));
|
||||
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
|
||||
target: {value: '第二条公告'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
|
||||
target: {value: '<section>轮播第二条</section>'},
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
|
||||
});
|
||||
const [, payload] = vi.mocked(upsertAdminCreationEntryBanners).mock.calls[0]!;
|
||||
expect(JSON.parse(payload.eventBannersJson)).toEqual([
|
||||
{
|
||||
title: '周末创作赛',
|
||||
htmlCode: '<section>新的入口公告</section>',
|
||||
},
|
||||
{
|
||||
title: '第二条公告',
|
||||
htmlCode: '<section>轮播第二条</section>',
|
||||
},
|
||||
]);
|
||||
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
|
||||
'description',
|
||||
);
|
||||
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
|
||||
'coverImageSrc',
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||
...configResponse,
|
||||
eventBanners: [
|
||||
{
|
||||
title: '旧公告 <标题>',
|
||||
description: '旧描述 & 需要转义',
|
||||
coverImageSrc: '/legacy.png',
|
||||
prizePoolMudPoints: 120,
|
||||
startsAtText: '2026-06-01',
|
||||
endsAtText: '2026-06-30',
|
||||
renderMode: 'structured',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('公告 1 标题')).toHaveProperty(
|
||||
'value',
|
||||
'旧公告 <标题>',
|
||||
);
|
||||
expect(screen.getByLabelText('公告 1 HTML')).toHaveProperty(
|
||||
'value',
|
||||
'<section><h1>旧公告 <标题></h1><p>旧描述 & 需要转义</p></section>',
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台拒绝空公告表单', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: {value: ''},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: {value: ''},
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
||||
|
||||
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,7 @@ npm run check:server-rs-ddd
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。
|
||||
- LLM 与语音:`/api/llm/*`、`/api/speech/volcengine/*`。
|
||||
- 资产:`/api/assets/*`,包括直传票据、STS、对象确认、实体绑定、读签名、读 bytes、历史资产、角色图像/动画和 Hyper3D 代理。
|
||||
- 创作入口配置:`/api/creation-entry/config` 与后台 `/admin/api/creation-entry/config`。
|
||||
- 创作入口配置:`/api/creation-entry/config`,后台 `/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。
|
||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||
- 拼图:`/api/runtime/puzzle/*`。
|
||||
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
||||
@@ -332,8 +332,8 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`CreationEntryConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`。
|
||||
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;运行态读取层再按 `module-runtime` 默认横幅归一,不覆盖后台已保存配置。
|
||||
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`。
|
||||
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。
|
||||
|
||||
### `creation_entry_type_config`
|
||||
|
||||
@@ -675,7 +675,7 @@ npm run check:server-rs-ddd
|
||||
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
|
||||
|
||||
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||
入口配置快照包含 start card、类型弹窗、活动横幅和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
|
||||
入口配置快照包含 start card、类型弹窗、公告位兼容字段和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
|
||||
|
||||
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
||||
|
||||
当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡;点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛` 和 `抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位:顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。
|
||||
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”来自真实后端作品架摘要,生成中和生成失败的草稿摘要都应进入最近创作并显示对应状态。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
|
||||
|
||||
创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
||||
|
||||
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。
|
||||
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recommended` / `热门推荐`,并把历史 `recent` / `最近创作` 归一到推荐分类。`最近创作` 不属于模板分类页签,只能由真实草稿 / 作品架后端数据决定是否展示。
|
||||
|
||||
移动端底部一级导航是全局平台样式,不按单一玩法分叉。当前视觉统一为米白浮动胶囊底座、浅棕分隔线、棕色线性图标、橘色选中态和底部短下划线;中间 `创作` 入口保持凸起圆形主按钮,但凸起位移只能作用在按钮内容层,不能移动承载分隔线的 Tab 按钮容器,确保创作左右分隔线与其他分隔线垂直位置一致。Tab 名称和可见性仍由现有 `PlatformHomeTab` / 登录态规则决定,样式调整不得改写 Tab 文案或导航状态。
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
||||
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
||||
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
|
||||
|
||||
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
|
||||
|
||||
|
||||
Generated
+361
-44
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,7 @@
|
||||
"@types/three": "^0.184.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
@@ -25,8 +25,8 @@ use shared_contracts::admin::{
|
||||
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
@@ -200,6 +200,7 @@ pub async fn admin_list_database_table_rows(
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
|
||||
/// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。
|
||||
pub async fn admin_get_creation_entry_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -212,6 +213,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -221,6 +223,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
|
||||
pub async fn admin_upsert_creation_entry_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -235,6 +238,38 @@ pub async fn admin_upsert_creation_entry_config(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存底部加号创作入口页的多公告表单序列化配置。
|
||||
pub async fn admin_upsert_creation_entry_event_banners_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertCreationEntryEventBannersRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let normalized_json =
|
||||
module_runtime::normalize_creation_entry_event_banners_json(&payload.event_banners_json)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||
let config = state
|
||||
.upsert_creation_entry_event_banners_config(
|
||||
module_runtime::CreationEntryEventBannersAdminUpsertInput {
|
||||
event_banners_json: normalized_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -1502,11 +1537,7 @@ mod tests {
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
use shared_contracts::admin::{
|
||||
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
|
||||
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
};
|
||||
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_absolute_url() {
|
||||
|
||||
@@ -518,6 +518,40 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 中文注释:后台路由测试通过真实登录流程取 token,避免绕过鉴权中间件。
|
||||
async fn read_admin_access_token(app: Router) -> String {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "root",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("admin login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("admin login request should succeed");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("admin login body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("admin login payload should be json");
|
||||
|
||||
payload["token"]
|
||||
.as_str()
|
||||
.expect("admin token should exist")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn password_login_request(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
@@ -3980,6 +4014,91 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// 中文注释:验证入口公告表单提交的 HTML 会保存进独立公告配置。
|
||||
#[tokio::test]
|
||||
async fn admin_creation_entry_banners_route_saves_html_form_payload() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/banners")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"eventBannersJson": serde_json::json!([
|
||||
{
|
||||
"title": "后台表单公告",
|
||||
"htmlCode": "<section>入口公告 HTML</section>"
|
||||
}
|
||||
]).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("banners request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("banners request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("banners body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("banners payload should be json");
|
||||
|
||||
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
|
||||
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
payload["eventBanners"][0]["htmlCode"],
|
||||
"<section>入口公告 HTML</section>"
|
||||
);
|
||||
}
|
||||
|
||||
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
|
||||
#[tokio::test]
|
||||
async fn admin_creation_entry_banners_route_rejects_script_html() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/banners")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"eventBannersJson": serde_json::json!([
|
||||
{
|
||||
"title": "危险公告",
|
||||
"htmlCode": "<script>alert(1)</script>"
|
||||
}
|
||||
]).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("banners request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("banners request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -152,7 +152,10 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
|
||||
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
|
||||
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||
updated_at_micros: 0,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use axum::{Router, middleware, routing::get};
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
|
||||
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
|
||||
admin_login, admin_me, admin_overview, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, require_admin_auth,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -71,6 +75,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/creation-entry/config/banners",
|
||||
post(admin_upsert_creation_entry_event_banners_config).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
|
||||
@@ -468,6 +468,45 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 SpacetimeDB 保存创作入口页多公告配置,并同步测试缓存。
|
||||
pub async fn upsert_creation_entry_event_banners_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
let test_event_banners_json = input.event_banners_json.clone();
|
||||
match self
|
||||
.spacetime_client
|
||||
.upsert_creation_entry_event_banners_config(input)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Ok(banners) = module_runtime::decode_creation_entry_event_banner_snapshots(
|
||||
test_event_banners_json.as_str(),
|
||||
) {
|
||||
config.event_banners = banners
|
||||
.into_iter()
|
||||
.map(module_runtime::build_creation_entry_event_banner_response)
|
||||
.collect();
|
||||
if let Some(first_banner) = config.event_banners.first().cloned() {
|
||||
config.event_banner = first_banner;
|
||||
}
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config(
|
||||
&self,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
|
||||
@@ -15,9 +15,19 @@ use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
|
||||
};
|
||||
|
||||
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
|
||||
pub fn build_creation_entry_config_response(
|
||||
snapshot: CreationEntryConfigSnapshot,
|
||||
) -> CreationEntryConfigResponse {
|
||||
let event_banners = resolve_creation_entry_event_banner_responses(
|
||||
snapshot.event_banners_json.as_deref(),
|
||||
&snapshot.event_banner,
|
||||
);
|
||||
let event_banner = event_banners
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| build_creation_entry_event_banner_response(snapshot.event_banner));
|
||||
|
||||
CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: snapshot.start_card.title,
|
||||
@@ -29,14 +39,8 @@ pub fn build_creation_entry_config_response(
|
||||
title: snapshot.type_modal.title,
|
||||
description: snapshot.type_modal.description,
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: snapshot.event_banner.title,
|
||||
description: snapshot.event_banner.description,
|
||||
cover_image_src: snapshot.event_banner.cover_image_src,
|
||||
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
|
||||
starts_at_text: snapshot.event_banner.starts_at_text,
|
||||
ends_at_text: snapshot.event_banner.ends_at_text,
|
||||
},
|
||||
event_banner,
|
||||
event_banners,
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -65,6 +69,273 @@ pub fn build_creation_entry_config_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
|
||||
pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEventBannerSnapshot> {
|
||||
vec![CreationEntryEventBannerSnapshot {
|
||||
title: "创作公告".to_string(),
|
||||
description: String::new(),
|
||||
cover_image_src: String::new(),
|
||||
prize_pool_mud_points: 0,
|
||||
starts_at_text: String::new(),
|
||||
ends_at_text: String::new(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some(
|
||||
r#"<section style="box-sizing:border-box;width:100%;min-height:180px;padding:28px 30px;border-radius:24px;background:#fff7ed;color:#6f2f21;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"><h1 style="margin:0 0 10px;font-size:28px;">创作公告</h1><p style="margin:0;font-size:16px;line-height:1.7;">这里可以在后台替换成你的公告 HTML。</p></section>"#
|
||||
.to_string(),
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
/// 生成默认公告 JSON,供 SpacetimeDB 表字段持久化。
|
||||
pub fn default_creation_entry_event_banners_json() -> String {
|
||||
encode_creation_entry_event_banner_snapshots(&default_creation_entry_event_banner_snapshots())
|
||||
.unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
/// 校验并归一后台公告表单序列化后的持久化 JSON。
|
||||
pub fn normalize_creation_entry_event_banners_json(input: &str) -> Result<String, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(default_creation_entry_event_banners_json());
|
||||
}
|
||||
|
||||
let banners = decode_creation_entry_event_banner_snapshots(trimmed)?;
|
||||
encode_creation_entry_event_banner_snapshots(&banners)
|
||||
}
|
||||
|
||||
/// 解析后台公告持久化 JSON,输出已归一化的领域快照。
|
||||
pub fn decode_creation_entry_event_banner_snapshots(
|
||||
input: &str,
|
||||
) -> Result<Vec<CreationEntryEventBannerSnapshot>, String> {
|
||||
let raw_value =
|
||||
serde_json::from_str::<Value>(input).map_err(|error| format!("公告 JSON 非法:{error}"))?;
|
||||
let banners = raw_value
|
||||
.as_array()
|
||||
.ok_or_else(|| "公告 JSON 必须是数组".to_string())?;
|
||||
if banners.is_empty() {
|
||||
return Err("公告至少需要配置一条".to_string());
|
||||
}
|
||||
if banners.len() > CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT {
|
||||
return Err(format!(
|
||||
"公告最多配置 {} 条",
|
||||
CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT
|
||||
));
|
||||
}
|
||||
|
||||
banners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, banner)| normalize_creation_entry_announcement_banner_value(index, banner))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 归一后台公告配置:新格式支持 HTML 字符串 / `{title, htmlCode}`,旧结构化 banner 保持兼容。
|
||||
fn normalize_creation_entry_announcement_banner_value(
|
||||
index: usize,
|
||||
value: &Value,
|
||||
) -> Result<CreationEntryEventBannerSnapshot, String> {
|
||||
if let Some(html_code) = value.as_str() {
|
||||
return build_creation_entry_html_announcement_snapshot(index, None, html_code.to_string());
|
||||
}
|
||||
|
||||
let Some(object) = value.as_object() else {
|
||||
return Err(format!("第 {} 条公告必须是 HTML 字符串或对象", index + 1));
|
||||
};
|
||||
let explicit_render_mode = value
|
||||
.get("renderMode")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !explicit_render_mode.is_empty() && !explicit_render_mode.eq_ignore_ascii_case("html") {
|
||||
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
|
||||
object.clone(),
|
||||
))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
return normalize_creation_entry_event_banner_response(index, banner);
|
||||
}
|
||||
if let Some(html_code) = read_announcement_html_code(value) {
|
||||
return build_creation_entry_html_announcement_snapshot(
|
||||
index,
|
||||
read_announcement_title(value),
|
||||
html_code,
|
||||
);
|
||||
}
|
||||
|
||||
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
|
||||
object.clone(),
|
||||
))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
normalize_creation_entry_event_banner_response(index, banner)
|
||||
}
|
||||
|
||||
/// 将后台公告 HTML 代码包装成前台沙箱 iframe 可渲染的 banner 快照。
|
||||
fn build_creation_entry_html_announcement_snapshot(
|
||||
index: usize,
|
||||
title: Option<String>,
|
||||
html_code: String,
|
||||
) -> Result<CreationEntryEventBannerSnapshot, String> {
|
||||
Ok(CreationEntryEventBannerSnapshot {
|
||||
title: title
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| format!("公告 {}", index + 1)),
|
||||
description: String::new(),
|
||||
cover_image_src: String::new(),
|
||||
prize_pool_mud_points: 0,
|
||||
starts_at_text: String::new(),
|
||||
ends_at_text: String::new(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: normalize_banner_html_code(index, "html", Some(html_code))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取公告对象标题,兼容 title/name 两种后台填写习惯。
|
||||
fn read_announcement_title(value: &Value) -> Option<String> {
|
||||
read_string_field(value, &["title", "name"])
|
||||
}
|
||||
|
||||
/// 读取公告 HTML 代码,兼容 htmlCode/html/code 三种后台填写习惯。
|
||||
fn read_announcement_html_code(value: &Value) -> Option<String> {
|
||||
read_string_field(value, &["htmlCode", "html", "code"])
|
||||
}
|
||||
|
||||
/// 从 JSON 对象读取第一个非空字符串字段。
|
||||
fn read_string_field(value: &Value, field_names: &[&str]) -> Option<String> {
|
||||
field_names.iter().find_map(|field_name| {
|
||||
value
|
||||
.get(*field_name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
/// 把公告领域快照编码为稳定 JSON。
|
||||
pub fn encode_creation_entry_event_banner_snapshots(
|
||||
banners: &[CreationEntryEventBannerSnapshot],
|
||||
) -> Result<String, String> {
|
||||
if banners.is_empty() {
|
||||
return Err("公告至少需要配置一条".to_string());
|
||||
}
|
||||
|
||||
let responses = banners
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string_pretty(&responses)
|
||||
.map_err(|error| format!("公告 JSON 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
/// 根据持久化 JSON 或旧单条字段得到前台可渲染公告列表。
|
||||
pub fn resolve_creation_entry_event_banner_responses(
|
||||
event_banners_json: Option<&str>,
|
||||
fallback_banner: &CreationEntryEventBannerSnapshot,
|
||||
) -> Vec<CreationEntryEventBannerResponse> {
|
||||
event_banners_json
|
||||
.and_then(|raw| decode_creation_entry_event_banner_snapshots(raw).ok())
|
||||
.filter(|banners| !banners.is_empty())
|
||||
.unwrap_or_else(|| vec![fallback_banner.clone()])
|
||||
.into_iter()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 把领域公告快照转换为 HTTP 响应字段。
|
||||
pub fn build_creation_entry_event_banner_response(
|
||||
banner: CreationEntryEventBannerSnapshot,
|
||||
) -> CreationEntryEventBannerResponse {
|
||||
CreationEntryEventBannerResponse {
|
||||
title: banner.title,
|
||||
description: banner.description,
|
||||
cover_image_src: banner.cover_image_src,
|
||||
prize_pool_mud_points: banner.prize_pool_mud_points,
|
||||
starts_at_text: banner.starts_at_text,
|
||||
ends_at_text: banner.ends_at_text,
|
||||
render_mode: normalize_banner_render_mode(&banner.render_mode),
|
||||
html_code: banner.html_code,
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验旧结构化 banner 响应并转换为领域公告快照。
|
||||
fn normalize_creation_entry_event_banner_response(
|
||||
index: usize,
|
||||
banner: CreationEntryEventBannerResponse,
|
||||
) -> Result<CreationEntryEventBannerSnapshot, String> {
|
||||
let render_mode = normalize_banner_render_mode(&banner.render_mode);
|
||||
let html_code = normalize_banner_html_code(index, render_mode.as_str(), banner.html_code)?;
|
||||
let default_banner = default_creation_entry_event_banner_snapshots()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("default banner should exist");
|
||||
|
||||
Ok(CreationEntryEventBannerSnapshot {
|
||||
title: normalize_banner_text(banner.title, default_banner.title),
|
||||
description: normalize_banner_text(banner.description, default_banner.description),
|
||||
cover_image_src: normalize_banner_text(
|
||||
banner.cover_image_src,
|
||||
default_banner.cover_image_src,
|
||||
),
|
||||
prize_pool_mud_points: banner.prize_pool_mud_points,
|
||||
starts_at_text: normalize_banner_text(banner.starts_at_text, default_banner.starts_at_text),
|
||||
ends_at_text: normalize_banner_text(banner.ends_at_text, default_banner.ends_at_text),
|
||||
render_mode,
|
||||
html_code,
|
||||
})
|
||||
}
|
||||
|
||||
/// 归一化公告渲染模式,未知值统一回到结构化兼容 UI。
|
||||
fn normalize_banner_render_mode(value: &str) -> String {
|
||||
if value.trim().eq_ignore_ascii_case("html") {
|
||||
"html".to_string()
|
||||
} else {
|
||||
"structured".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧结构化 banner 文案字段,空值沿用平台默认文案。
|
||||
fn normalize_banner_text(value: String, fallback: String) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 HTML 公告片段,只允许交给前端沙箱 iframe 展示。
|
||||
fn normalize_banner_html_code(
|
||||
index: usize,
|
||||
render_mode: &str,
|
||||
value: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if render_mode != "html" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let html_code = value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("第 {} 条 HTML 公告缺少 htmlCode", index + 1))?;
|
||||
if html_code.len() > CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"第 {} 条 HTML 公告超过 {} 字节",
|
||||
index + 1,
|
||||
CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES
|
||||
));
|
||||
}
|
||||
let lower_html_code = html_code.to_ascii_lowercase();
|
||||
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
|
||||
return Err(format!(
|
||||
"第 {} 条 HTML 公告含有不允许的脚本代码",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(html_code))
|
||||
}
|
||||
|
||||
pub fn default_creation_entry_type_snapshots(
|
||||
updated_at_micros: i64,
|
||||
) -> Vec<CreationEntryTypeSnapshot> {
|
||||
@@ -78,9 +349,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
10,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -106,9 +377,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
30,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -120,9 +391,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
40,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
|
||||
@@ -51,8 +51,9 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab";
|
||||
pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启";
|
||||
pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型";
|
||||
pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作";
|
||||
/// 创作模板分类缺省回退到推荐,不再把真实作品维度的“最近创作”种成模板页签。
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recommended";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "热门推荐";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
|
||||
@@ -60,6 +61,10 @@ pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
|
||||
/// 后台创作入口公告最多允许配置的轮播条数。
|
||||
pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
|
||||
/// 单条 HTML 公告的代码大小上限,避免后台误贴超大片段拖慢入口页。
|
||||
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -77,6 +82,7 @@ pub struct CreationEntryTypeModalSnapshot {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 创作入口公告快照,支持 HTML 公告渲染和旧结构化 banner 兼容。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryEventBannerSnapshot {
|
||||
@@ -86,6 +92,8 @@ pub struct CreationEntryEventBannerSnapshot {
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
pub render_mode: String,
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -106,6 +114,7 @@ pub struct CreationEntryTypeSnapshot {
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 创作入口全局配置快照,供前台入口页和后台配置页共同读取。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryConfigSnapshot {
|
||||
@@ -113,6 +122,8 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub start_card: CreationEntryStartCardSnapshot,
|
||||
pub type_modal: CreationEntryTypeModalSnapshot,
|
||||
pub event_banner: CreationEntryEventBannerSnapshot,
|
||||
/// 底部加号创作入口页的多公告 JSON 配置;旧库为空时由应用层兜底。
|
||||
pub event_banners_json: Option<String>,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
@@ -134,6 +145,14 @@ pub struct CreationEntryTypeAdminUpsertInput {
|
||||
pub unified_creation_spec_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口多公告表单序列化结果的领域输入。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryEventBannersAdminUpsertInput {
|
||||
/// 持久化字段沿用 JSON 字符串,内容由后台表单生成。
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryConfigProcedureResult {
|
||||
|
||||
@@ -253,11 +253,135 @@ mod tests {
|
||||
assert!(rpg.open);
|
||||
assert_eq!(rpg.badge, "可创建");
|
||||
assert_eq!(rpg.sort_order, 10);
|
||||
assert_eq!(rpg.category_id, "recent");
|
||||
assert_eq!(rpg.category_label, "最近创作");
|
||||
assert_eq!(rpg.category_id, "recommended");
|
||||
assert_eq!(rpg.category_label, "热门推荐");
|
||||
assert_eq!(rpg.category_sort_order, 20);
|
||||
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_do_not_seed_recent_as_template_category() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
|
||||
assert!(configs.iter().all(|item| item.category_id != "recent"));
|
||||
assert!(configs.iter().all(|item| item.category_label != "最近创作"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_normalizes_multiple_banners() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
{
|
||||
"title": " 周末拼图赛 ",
|
||||
"description": " 拼一个新主题 ",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 1200,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30",
|
||||
"renderMode": "structured",
|
||||
"htmlCode": "<div>ignored</div>"
|
||||
},
|
||||
{
|
||||
"title": "HTML 横幅",
|
||||
"description": "沙箱片段",
|
||||
"coverImageSrc": "/creation-type-references/match3d.webp",
|
||||
"prizePoolMudPoints": 900,
|
||||
"startsAtText": "2026-07-01",
|
||||
"endsAtText": "2026-07-31",
|
||||
"renderMode": "html",
|
||||
"htmlCode": " <section>安全片段</section> "
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect("valid banner json should normalize");
|
||||
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
||||
.expect("normalized banner json should decode");
|
||||
|
||||
assert_eq!(banners.len(), 2);
|
||||
assert_eq!(banners[0].title, "周末拼图赛");
|
||||
assert_eq!(banners[0].description, "拼一个新主题");
|
||||
assert_eq!(banners[0].render_mode, "structured");
|
||||
assert!(banners[0].html_code.is_none());
|
||||
assert_eq!(banners[1].render_mode, "html");
|
||||
assert_eq!(
|
||||
banners[1].html_code.as_deref(),
|
||||
Some("<section>安全片段</section>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_empty_input_returns_defaults() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(" ")
|
||||
.expect("blank banner json should use defaults");
|
||||
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
||||
.expect("default banner json should decode");
|
||||
|
||||
assert_eq!(banners, default_creation_entry_event_banner_snapshots());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_accepts_announcement_html_code() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
"<section>纯 HTML 公告</section>",
|
||||
{"title": "后台公告", "htmlCode": "<article>自定义公告</article>"}
|
||||
]"#,
|
||||
)
|
||||
.expect("announcement html json should normalize");
|
||||
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
||||
.expect("normalized announcement json should decode");
|
||||
|
||||
assert_eq!(banners.len(), 2);
|
||||
assert_eq!(banners[0].title, "公告 1");
|
||||
assert_eq!(banners[0].render_mode, "html");
|
||||
assert_eq!(
|
||||
banners[0].html_code.as_deref(),
|
||||
Some("<section>纯 HTML 公告</section>")
|
||||
);
|
||||
assert_eq!(banners[1].title, "后台公告");
|
||||
assert_eq!(
|
||||
banners[1].html_code.as_deref(),
|
||||
Some("<article>自定义公告</article>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_rejects_script_like_html() {
|
||||
let script_error = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
{
|
||||
"title": "脚本横幅",
|
||||
"description": "不允许脚本",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 100,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30",
|
||||
"renderMode": "html",
|
||||
"htmlCode": "<script>alert(1)</script>"
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect_err("script tag should be rejected");
|
||||
let javascript_url_error = normalize_creation_entry_event_banners_json(
|
||||
r#"[
|
||||
{
|
||||
"title": "链接横幅",
|
||||
"description": "不允许 javascript URL",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 100,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30",
|
||||
"renderMode": "html",
|
||||
"htmlCode": "<a href=\"javascript:alert(1)\">bad</a>"
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect_err("javascript url should be rejected");
|
||||
|
||||
assert!(script_error.contains("脚本代码"));
|
||||
assert!(javascript_url_error.contains("脚本代码"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_include_bark_battle() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::creation_entry_config::UnifiedCreationSpecResponse;
|
||||
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
|
||||
|
||||
// 管理后台协议统一收口在 shared-contracts,避免页面脚本和 Rust handler 各自手拼字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -18,6 +18,8 @@ pub struct AdminLoginRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminCreationEntryConfigResponse {
|
||||
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
|
||||
/// 底部加号创作入口页的后台公告列表。
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
}
|
||||
|
||||
/// 后台单个创作入口开关配置。
|
||||
@@ -59,6 +61,14 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口公告表单序列化结果请求。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertCreationEntryEventBannersRequest {
|
||||
/// 传输字段沿用既有契约,内容由后台标题 / HTML 表单生成。
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 前台创作入口配置响应。
|
||||
///
|
||||
/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取
|
||||
/// `event_banners`,由后台表单配置多条公告并支持轮播。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryConfigResponse {
|
||||
pub start_card: CreationEntryStartCardResponse,
|
||||
pub type_modal: CreationEntryTypeModalResponse,
|
||||
pub event_banner: CreationEntryEventBannerResponse,
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
pub creation_types: Vec<CreationEntryTypeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryStartCardResponse {
|
||||
pub title: String,
|
||||
@@ -19,14 +25,19 @@ pub struct CreationEntryStartCardResponse {
|
||||
pub busy_badge: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作类型选择弹层的基础文案契约。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeModalResponse {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口单条公告。
|
||||
///
|
||||
/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示;
|
||||
/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryEventBannerResponse {
|
||||
pub title: String,
|
||||
@@ -35,9 +46,19 @@ pub struct CreationEntryEventBannerResponse {
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
#[serde(default = "default_creation_entry_event_banner_render_mode")]
|
||||
pub render_mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 默认渲染模式使用受控结构化 UI,用于旧数据兼容。
|
||||
pub fn default_creation_entry_event_banner_render_mode() -> String {
|
||||
"structured".to_string()
|
||||
}
|
||||
|
||||
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeResponse {
|
||||
pub id: String,
|
||||
@@ -56,6 +77,7 @@ pub struct CreationEntryTypeResponse {
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationSpecResponse {
|
||||
@@ -281,4 +303,68 @@ mod tests {
|
||||
assert!(build_phase1_unified_creation_spec("visual-novel").is_none());
|
||||
assert!(build_phase1_unified_creation_spec("bark-battle").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
|
||||
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
|
||||
r#"{
|
||||
"title": "旧版横幅",
|
||||
"description": "兼容旧字段",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 1000,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30"
|
||||
}"#,
|
||||
)
|
||||
.expect("legacy banner json should decode");
|
||||
|
||||
assert_eq!(banner.render_mode, "structured");
|
||||
assert!(banner.html_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_config_serializes_event_banners_contract() {
|
||||
let response = CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: "新建作品".to_string(),
|
||||
description: "选择模板".to_string(),
|
||||
idle_badge: "模板".to_string(),
|
||||
busy_badge: "开启中".to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalResponse {
|
||||
title: "选择创作类型".to_string(),
|
||||
description: "先选玩法".to_string(),
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: "第一条".to_string(),
|
||||
description: "兼容单条".to_string(),
|
||||
cover_image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
prize_pool_mud_points: 1000,
|
||||
starts_at_text: "2026-06-01".to_string(),
|
||||
ends_at_text: "2026-06-30".to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners: vec![CreationEntryEventBannerResponse {
|
||||
title: "HTML 条".to_string(),
|
||||
description: "沙箱".to_string(),
|
||||
cover_image_src: "/creation-type-references/match3d.webp".to_string(),
|
||||
prize_pool_mud_points: 800,
|
||||
starts_at_text: "2026-07-01".to_string(),
|
||||
ends_at_text: "2026-07-31".to_string(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some("<section>ok</section>".to_string()),
|
||||
}],
|
||||
creation_types: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(response).expect("response should serialize");
|
||||
|
||||
assert_eq!(value["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
value["eventBanners"][0]["htmlCode"],
|
||||
"<section>ok</section>"
|
||||
);
|
||||
assert!(value.get("event_banner").is_none());
|
||||
assert!(value.get("eventBanner").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user