feat(characters): enable isolated pre-release role switching
Docker Image / Quality and Bundle Budgets (push) Successful in 4s
Docker Image / Build and Push Docker Image (push) Successful in 1m52s

This commit is contained in:
Codex
2026-07-20 11:03:41 +08:00
parent 00ec1aead0
commit bc84d2c5d6
17 changed files with 194 additions and 47 deletions
+15 -15
View File
@@ -16,7 +16,7 @@
| 类型 | Elio 示例 | 用途 |
| --- | --- | --- |
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 |
| `id` | `elio` | 数据库关联、API 请求和缓存隔离 |
| `slug` | `elio` | 前端 URL 和外部入口 |
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
@@ -37,9 +37,9 @@
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
| --- | --- | --- | --- | --- | --- |
| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
| `elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
| `maya-tan` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
@@ -56,7 +56,7 @@ POST /api/chat/unlock-private
POST /api/chat/unlock-history
```
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
`characterId` 使用前端本地角色目录中的 `id`,例如 `elio`,不能传 slug 或显示名称。
后端必须使用以下维度定位聊天数据:
@@ -79,7 +79,7 @@ Authorization: Bearer <TOKEN>
```json
{
"characterId": "character_elio",
"characterId": "elio",
"message": "Hello Elio",
"image": "",
"imageId": "",
@@ -105,7 +105,7 @@ Authorization: Bearer <TOKEN>
### 3.3 获取聊天历史
```http
GET /api/chat/history?characterId=character_elio&limit=50&offset=0
GET /api/chat/history?characterId=elio&limit=50&offset=0
Authorization: Bearer <TOKEN>
```
@@ -131,7 +131,7 @@ Authorization: Bearer <TOKEN>
```json
{
"characterId": "character_elio",
"characterId": "elio",
"messageId": "message-id",
"lockType": "voice_message",
"clientLockId": "client-generated-id"
@@ -164,7 +164,7 @@ Authorization: Bearer <TOKEN>
```json
{
"characterId": "character_elio"
"characterId": "elio"
}
```
@@ -190,7 +190,7 @@ Authorization: Bearer <TOKEN>
"data": {
"items": [
{
"characterId": "character_elio",
"characterId": "elio",
"message": {
"id": "message-id",
"role": "assistant",
@@ -220,7 +220,7 @@ Authorization: Bearer <TOKEN>
标准请求改为:
```http
GET /api/private-room/albums?characterId=character_elio&limit=20
GET /api/private-room/albums?characterId=elio&limit=20
Authorization: Bearer <TOKEN>
```
@@ -266,7 +266,7 @@ POST /api/payment/create-order
"planId": "tip_coffee_medium",
"payChannel": "stripe",
"autoRenew": false,
"recipientCharacterId": "character_elio"
"recipientCharacterId": "elio"
}
```
@@ -326,9 +326,9 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
上线新接口前执行:
1. 创建 Elio 角色,固定 `id=character_elio``slug=elio`
1. 创建 Elio 角色,固定 `id=elio``slug=elio`
2. 为相关业务表增加可空 `character_id`
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `elio`
4. 检查不存在空角色或无法关联的数据。
5. 创建联合索引和外键。
6. 将必要业务表的 `character_id` 调整为非空。
@@ -340,7 +340,7 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
### 第一阶段:后端兼容
- 上线角色 ID 配置和新字段。
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`
- Chat 请求缺少 `characterId` 时暂时默认 `elio`
- Private Room 缺少角色时暂时默认 Elio。
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
- 记录缺少角色参数的调用次数和客户端版本。
@@ -0,0 +1,34 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { CHARACTERS, getCharacterBySlug } from "@/data/constants/character";
import { CharacterCatalogProvider } from "@/providers/character-catalog-provider";
import { CharacterProvider } from "@/providers/character-provider";
import { CharacterSwitcher } from "../character-switcher";
describe("CharacterSwitcher", () => {
it("links every chat character and marks the active account conversation", () => {
const maya = getCharacterBySlug("maya");
expect(maya).not.toBeNull();
const html = renderToStaticMarkup(
<CharacterCatalogProvider characters={CHARACTERS}>
<CharacterProvider character={maya!}>
<CharacterSwitcher />
</CharacterProvider>
</CharacterCatalogProvider>,
);
expect(html).toContain('aria-label="Choose character"');
expect(html).toContain('href="/characters/elio/splash"');
expect(html).toContain('href="/characters/maya/splash"');
expect(html).toContain('href="/characters/nayeli/splash"');
expect(html).toContain('data-character-id="elio"');
expect(html).toContain('data-character-id="maya-tan"');
expect(html).toContain('data-character-id="nayeli-cervantes"');
expect(html).toMatch(
/<a(?=[^>]*data-character-id="maya-tan")(?=[^>]*aria-current="page")[^>]*>/,
);
});
});
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { getCharacterRoutes } from "@/router/routes";
import { useActiveCharacter } from "@/providers/character-provider";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { CharacterAvatar } from "./character-avatar";
export interface CharacterSwitcherProps {
destination?: "splash" | "chat";
}
export function CharacterSwitcher({
destination = "splash",
}: CharacterSwitcherProps) {
const activeCharacter = useActiveCharacter();
const catalog = useCharacterCatalog();
const chatCharacters = catalog.characters.filter(
(character) => character.capabilities.chat,
);
return (
<nav
aria-label="Choose character"
className="grid w-full grid-cols-3 gap-1 rounded-lg border border-white/15 bg-black/35 p-1 shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-md"
>
{chatCharacters.map((character) => {
const isActive = character.id === activeCharacter.id;
const routes = getCharacterRoutes(character.slug);
return (
<Link
key={character.id}
href={routes[destination]}
aria-current={isActive ? "page" : undefined}
aria-label={`Chat with ${character.displayName}`}
title={character.displayName}
data-character-id={character.id}
data-analytics-key="character.switch"
data-analytics-label={`Switch to ${character.displayName}`}
className={[
"flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1.5 py-1.5 text-[11px] font-semibold text-white transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
isActive
? "bg-white/20 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
: "bg-transparent hover:bg-white/10",
].join(" ")}
>
<CharacterAvatar
src={character.assets.avatar}
alt=""
size={28}
imageSize={56}
className={
isActive
? "ring-2 ring-white ring-offset-1 ring-offset-transparent"
: "opacity-85"
}
/>
<span className="min-w-0 truncate">{character.shortName}</span>
</Link>
);
})}
</nav>
);
}
@@ -93,6 +93,15 @@ describe("core Tailwind components", () => {
onPrivateRoomClick={() => undefined}
/>,
);
const disabledPrivateRoomHtml = renderToStaticMarkup(
<AppBottomNav
activeItem="chat"
privateRoomLabel="Maya Private room"
privateRoomEnabled={false}
onChatClick={() => undefined}
onPrivateRoomClick={() => undefined}
/>,
);
expect(chatHtml).toContain('aria-label="Primary navigation"');
expect(chatHtml).toMatch(/class="[^"]*dark[^"]*"/);
@@ -107,5 +116,8 @@ describe("core Tailwind components", () => {
expect(privateRoomHtml).toContain(
'data-analytics-key="navigation.private_room"',
);
expect(disabledPrivateRoomHtml).toMatch(
/<button[^>]*aria-disabled="true"[^>]*disabled=""[^>]*>/,
);
});
});
@@ -80,6 +80,20 @@
outline-offset: 3px;
}
.disabled {
cursor: not-allowed;
opacity: 0.42;
}
.disabled:hover {
background: transparent;
color: var(--nav-button-color);
}
.disabled:active {
transform: none;
}
.active {
color: var(--nav-active-color);
}
+16 -3
View File
@@ -11,6 +11,7 @@ export interface AppBottomNavProps {
activeItem?: AppBottomNavItem | null;
variant?: AppBottomNavVariant;
privateRoomLabel: string;
privateRoomEnabled?: boolean;
onChatClick: () => void;
onPrivateRoomClick: () => void;
}
@@ -19,6 +20,7 @@ export function AppBottomNav({
activeItem = null,
variant = "warm",
privateRoomLabel,
privateRoomEnabled = true,
onChatClick,
onPrivateRoomClick,
}: AppBottomNavProps) {
@@ -39,8 +41,13 @@ export function AppBottomNav({
type="button"
data-analytics-key="navigation.private_room"
data-analytics-label="Private room navigation"
className={getButtonClass(activeItem === "privateRoom")}
className={getButtonClass(
activeItem === "privateRoom",
!privateRoomEnabled,
)}
aria-current={activeItem === "privateRoom" ? "page" : undefined}
aria-disabled={!privateRoomEnabled}
disabled={!privateRoomEnabled}
onClick={onPrivateRoomClick}
>
<Camera size={20} aria-hidden="true" />
@@ -54,6 +61,12 @@ function getRootClass(variant: AppBottomNavVariant): string {
return [styles.root, styles[variant]].filter(Boolean).join(" ");
}
function getButtonClass(active: boolean): string {
return [styles.button, active ? styles.active : ""].filter(Boolean).join(" ");
function getButtonClass(active: boolean, disabled = false): string {
return [
styles.button,
active ? styles.active : "",
disabled ? styles.disabled : "",
]
.filter(Boolean)
.join(" ");
}
+5
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { CharacterSwitcher } from "@/app/_components/character-switcher";
import { AppBottomNav, MobileShell } from "@/app/_components/core";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
@@ -57,6 +58,9 @@ export function SplashScreen() {
<div className={styles.gradientOverlay} aria-hidden="true" />
<div className={styles.content}>
<SplashLogo />
<div className="mt-3 w-full max-w-120">
<CharacterSwitcher />
</div>
<div className={styles.spacer} />
<SplashLatestMessage
message={latestMessage.message}
@@ -78,6 +82,7 @@ export function SplashScreen() {
<AppBottomNav
activeItem="chat"
privateRoomLabel={character.copy.privateRoomTitle}
privateRoomEnabled={character.capabilities.privateRoom}
onChatClick={handleOpenSplash}
onPrivateRoomClick={handleOpenPrivateRoom}
/>
@@ -40,7 +40,7 @@ describe("local character catalog", () => {
});
it("resolves characters by id and normalized slug", () => {
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
expect(getCharacterById("maya-tan")?.displayName).toBe("Maya Tan");
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
"Nayeli Cervantes",
);
@@ -50,11 +50,11 @@ describe("local character catalog", () => {
it("describes route capabilities and character-specific chat copy", () => {
for (const character of CHARACTERS) {
expect(character.capabilities).toEqual({
chat: true,
privateRoom: true,
tip: true,
});
expect(character.capabilities.chat).toBe(true);
expect(character.capabilities.tip).toBe(true);
expect(character.capabilities.privateRoom).toBe(
character.id === "elio",
);
expect(character.tagline.length).toBeGreaterThan(0);
expect(character.emptyChatGreeting.length).toBeGreaterThan(0);
expect(character.assets.chatBackground).toBe(
+5 -5
View File
@@ -45,7 +45,7 @@ function defineCharacter(profile: CharacterProfile): CharacterProfile {
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
defineCharacter({
id: "character_elio",
id: "elio",
slug: "elio",
displayName: "Elio Silvestri",
shortName: "Elio",
@@ -75,7 +75,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
},
}),
defineCharacter({
id: "character_maya",
id: "maya-tan",
slug: "maya",
displayName: "Maya Tan",
shortName: "Maya",
@@ -86,7 +86,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
"How was your day?",
capabilities: {
chat: true,
privateRoom: true,
privateRoom: false,
tip: true,
},
assets: {
@@ -104,7 +104,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
},
}),
defineCharacter({
id: "character_nayeli",
id: "nayeli-cervantes",
slug: "nayeli",
displayName: "Nayeli Cervantes",
shortName: "Nayeli",
@@ -115,7 +115,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
"Tell me how your day went.",
capabilities: {
chat: true,
privateRoom: true,
privateRoom: false,
tip: true,
},
assets: {
@@ -1,5 +1,5 @@
{
"characterId": "character_elio",
"characterId": "elio",
"message": "Look at this sunset.",
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
"imageId": "img_mock_001",
@@ -1,5 +1,5 @@
{
"characterId": "character_elio",
"characterId": "elio",
"message": "I missed you today.",
"image": "",
"imageId": "",
@@ -1,4 +1,4 @@
{
"characterId": "character_elio",
"characterId": "elio",
"messageId": "msg_private_locked_001"
}
@@ -31,7 +31,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
expect(result).toEqual(Result.ok(null));
expect(getMedia).toHaveBeenCalledTimes(1);
expect(getMedia).toHaveBeenCalledWith(
"user:account-a::character:character_elio:message-1:image",
"user:account-a::character:elio:message-1:image",
);
});
@@ -46,19 +46,19 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
);
await coordinator.getCachedMedia({
characterId: "character_elio",
characterId: "elio",
messageId: "shared-message-id",
kind: "image",
});
await coordinator.getCachedMedia({
characterId: "character_aria",
characterId: "maya-tan",
messageId: "shared-message-id",
kind: "image",
});
expect(getMedia.mock.calls).toEqual([
["user:account-a::character:character_elio:shared-message-id:image"],
["user:account-a::character:character_aria:shared-message-id:image"],
["user:account-a::character:elio:shared-message-id:image"],
["user:account-a::character:maya-tan:shared-message-id:image"],
]);
});
@@ -112,7 +112,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
kind: "image",
remoteUrl: "https://media.example/expired.jpg",
},
"user:account-a::character:character_elio",
"user:account-a::character:elio",
);
expect(Result.isErr(result)).toBe(true);
@@ -15,7 +15,7 @@ vi.mock("../http_client", () => ({
import { ChatApi } from "../chat_api";
import { PrivateRoomApi } from "../private_room_api";
const CHARACTER_ID = "character_elio";
const CHARACTER_ID = "elio";
describe("multi-character API contract", () => {
beforeEach(() => {
@@ -32,7 +32,7 @@ describe("fetchSplashLatestMessagePreview", () => {
const result = await fetchSplashLatestMessagePreview();
expect(getHistoryMock).toHaveBeenCalledWith(
"character_elio",
"elio",
1,
0,
);
+5 -1
View File
@@ -24,13 +24,17 @@ export function CharacterProvider({
}
export function useActiveCharacter(): CharacterProfile {
const character = useContext(CharacterContext);
const character = useOptionalActiveCharacter();
if (!character) {
throw new Error("useActiveCharacter must be used within CharacterProvider.");
}
return character;
}
export function useOptionalActiveCharacter(): CharacterProfile | null {
return useContext(CharacterContext);
}
export function useActiveCharacterRoutes(): CharacterRoutes {
return getCharacterRoutes(useActiveCharacter().slug);
}
+5 -6
View File
@@ -16,12 +16,11 @@ import {
} from "@/lib/analytics";
import { useAuthSelector } from "@/stores/auth/auth-context";
import { useUserSelector } from "@/stores/user/user-context";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { DEFAULT_CHARACTER } from "@/data/constants/character";
import { useOptionalActiveCharacter } from "@/providers/character-provider";
import {
getCharacterRoutes,
ROUTE_BUILDERS,
type Route,
} from "./routes";
@@ -60,8 +59,8 @@ export interface AppNavigator {
export function useAppNavigator(): AppNavigator {
const router = useRouter();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const character = useOptionalActiveCharacter() ?? DEFAULT_CHARACTER;
const characterRoutes = getCharacterRoutes(character.slug);
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
const countryCode = useUserSelector(
(state) => state.context.currentUser?.countryCode,