feat(characters): use local character catalog

This commit is contained in:
2026-07-17 16:03:18 +08:00
parent a210a98d98
commit b3ebd5cf3b
96 changed files with 1023 additions and 522 deletions
@@ -0,0 +1,47 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
CHARACTERS,
DEFAULT_CHARACTER,
getCharacterById,
getCharacterBySlug,
} from "@/data/constants/character";
describe("local character catalog", () => {
it("contains immutable and uniquely addressable character profiles", () => {
expect(CHARACTERS.map((character) => character.slug)).toEqual([
"elio",
"maya",
"nayeli",
]);
expect(new Set(CHARACTERS.map((character) => character.id)).size).toBe(
CHARACTERS.length,
);
expect(Object.isFrozen(CHARACTERS)).toBe(true);
expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true);
expect(Object.isFrozen(DEFAULT_CHARACTER.assets)).toBe(true);
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
});
it("resolves characters by id and normalized slug", () => {
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
"Nayeli Cervantes",
);
expect(getCharacterById("missing")).toBeNull();
expect(getCharacterBySlug("missing")).toBeNull();
});
it("references local assets that exist under public", () => {
for (const character of CHARACTERS) {
for (const assetPath of Object.values(character.assets)) {
expect(
existsSync(join(process.cwd(), "public", assetPath)),
assetPath,
).toBe(true);
}
}
});
});
+104 -2
View File
@@ -1,2 +1,104 @@
export const DEFAULT_CHARACTER_ID = "character_elio";
export const DEFAULT_CHARACTER_SLUG = "elio";
export interface CharacterProfile {
readonly id: string;
readonly slug: string;
readonly displayName: string;
readonly shortName: string;
readonly assets: {
readonly avatar: string;
readonly cover: string;
readonly privateRoomBanner: string;
};
readonly copy: {
readonly splashRelationship: string;
readonly privateRoomTitle: string;
readonly privateRoomSubtitle: string;
readonly tipHeader: string;
readonly tipTitle: string;
};
}
function defineCharacter(profile: CharacterProfile): CharacterProfile {
return Object.freeze({
...profile,
assets: Object.freeze(profile.assets),
copy: Object.freeze(profile.copy),
});
}
export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
defineCharacter({
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
shortName: "Elio",
assets: {
avatar: "/images/avatar/elio.png",
cover: "/images/cover/elio.png",
privateRoomBanner: "/images/private-room/banner/elio.png",
},
copy: {
splashRelationship: "Your exclusive AI boyfriend",
privateRoomTitle: "Elio Private room",
privateRoomSubtitle: "Join me, unlock my private room",
tipHeader: "Tip Elio",
tipTitle: "Buy Elio a coffee",
},
}),
defineCharacter({
id: "character_maya",
slug: "maya",
displayName: "Maya Tan",
shortName: "Maya",
assets: {
avatar: "/images/avatar/maya.png",
cover: "/images/cover/maya.png",
privateRoomBanner: "/images/private-room/banner/maya.png",
},
copy: {
splashRelationship: "Your exclusive AI girlfriend",
privateRoomTitle: "Maya Private room",
privateRoomSubtitle: "Join me, unlock my private room",
tipHeader: "Tip Maya",
tipTitle: "Buy Maya a coffee",
},
}),
defineCharacter({
id: "character_nayeli",
slug: "nayeli",
displayName: "Nayeli Cervantes",
shortName: "Nayeli",
assets: {
avatar: "/images/avatar/nayeli.png",
cover: "/images/cover/nayeli.png",
privateRoomBanner: "/images/private-room/banner/nayeli.png",
},
copy: {
splashRelationship: "Your exclusive AI girlfriend",
privateRoomTitle: "Nayeli Private room",
privateRoomSubtitle: "Join me, unlock my private room",
tipHeader: "Tip Nayeli",
tipTitle: "Buy Nayeli a coffee",
},
}),
]);
export const DEFAULT_CHARACTER = CHARACTERS[0];
export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id;
export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
export function getCharacterById(
characterId: string | null | undefined,
): CharacterProfile | null {
if (!characterId) return null;
return CHARACTERS.find((character) => character.id === characterId) ?? null;
}
export function getCharacterBySlug(
characterSlug: string | null | undefined,
): CharacterProfile | null {
const normalizedSlug = characterSlug?.trim().toLowerCase();
if (!normalizedSlug) return null;
return (
CHARACTERS.find((character) => character.slug === normalizedSlug) ?? null
);
}
@@ -1,18 +0,0 @@
import type { CharactersResponse } from "@/data/schemas/character";
import type { ICharacterRepository } from "@/data/repositories/interfaces";
import { CharacterApi, characterApi } from "@/data/services/api/character_api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class CharacterRepository implements ICharacterRepository {
constructor(private readonly api: CharacterApi) {}
getCharacters(): Promise<Result<CharactersResponse>> {
return Result.wrap(() => this.api.getCharacters());
}
}
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
() => new CharacterRepository(characterApi),
);
-2
View File
@@ -4,7 +4,6 @@
export * from "./auth_repository";
export * from "./chat_repository";
export * from "./character_repository";
export * from "./feedback_repository";
export * from "./metrics_repository";
export * from "./payment_repository";
@@ -12,7 +11,6 @@ export * from "./private_room_repository";
export * from "./user_repository";
export * from "./interfaces/iauth_repository";
export * from "./interfaces/ichat_repository";
export * from "./interfaces/icharacter_repository";
export * from "./interfaces/ifeedback_repository";
export * from "./interfaces/imetrics_repository";
export * from "./interfaces/ipayment_repository";
@@ -1,6 +0,0 @@
import type { CharactersResponse } from "@/data/schemas/character";
import type { Result } from "@/utils/result";
export interface ICharacterRepository {
getCharacters(): Promise<Result<CharactersResponse>>;
}
@@ -4,7 +4,6 @@
export * from "./iauth_repository";
export * from "./ichat_repository";
export * from "./icharacter_repository";
export * from "./ifeedback_repository";
export * from "./imetrics_repository";
export * from "./ipayment_repository";
@@ -27,6 +27,7 @@ export interface IPaymentRepository {
planId: string,
payChannel: PayChannel,
autoRenew: boolean,
recipientCharacterId?: string,
): Promise<Result<CreatePaymentOrderResponse>>;
/** 查询支付订单状态。 */
@@ -72,11 +72,13 @@ export class PaymentRepository implements IPaymentRepository {
planId: string,
payChannel: PayChannel,
autoRenew: boolean,
recipientCharacterId?: string,
): Promise<Result<CreatePaymentOrderResponse>> {
const request = CreatePaymentOrderRequestSchema.parse({
planId,
payChannel,
autoRenew,
...(recipientCharacterId ? { recipientCharacterId } : {}),
});
return Result.wrap(() => this.api.createOrder(request));
}
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { CharacterSchema } from "@/data/schemas/character";
describe("Character", () => {
it("keeps only the four public character fields", () => {
const character = CharacterSchema.parse({
id: "character_elio",
slug: "elio",
displayName: " Elio Silvestri ",
avatarUrl: "https://cdn.example.com/elio.jpg",
enabled: true,
sortOrder: 1,
});
expect(character).toEqual({
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
avatarUrl: "https://cdn.example.com/elio.jpg",
});
});
it("rejects unsafe slugs and non-HTTPS avatars", () => {
expect(() =>
CharacterSchema.parse({
id: "character_aria",
slug: "Aria Profile",
displayName: "Aria",
avatarUrl: "https://cdn.example.com/aria.jpg",
}),
).toThrow();
expect(() =>
CharacterSchema.parse({
id: "character_aria",
slug: "aria",
displayName: "Aria",
avatarUrl: "http://cdn.example.com/aria.jpg",
}),
).toThrow();
});
});
-17
View File
@@ -1,17 +0,0 @@
import { z } from "zod";
export const CharacterSchema = z
.object({
id: z.string().min(1).max(64),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
displayName: z.string().trim().min(1).max(80),
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
message: "avatarUrl must use HTTPS",
}),
})
.readonly();
export type CharacterInput = z.input<typeof CharacterSchema>;
export type CharacterData = z.output<typeof CharacterSchema>;
export type Character = CharacterData;
@@ -1,17 +0,0 @@
import { z } from "zod";
import { CharacterSchema } from "./character";
export const CharactersResponseSchema = z
.object({
items: z
.array(CharacterSchema)
.default(() => [])
.readonly(),
})
.readonly();
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
export type CharactersResponse = CharactersResponseData;
-6
View File
@@ -1,6 +0,0 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./character";
export * from "./characters_response";
@@ -10,6 +10,7 @@ export const CreatePaymentOrderRequestSchema = z
planId: z.string(),
payChannel: PayChannelSchema,
autoRenew: z.boolean(),
recipientCharacterId: z.string().min(1).optional(),
})
.readonly();
@@ -1,45 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { CharacterApi } from "../character_api";
describe("CharacterApi", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("loads characters and preserves the backend order", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
items: [
{
id: "character_aria",
slug: "aria",
displayName: "Aria",
avatarUrl: "https://cdn.example.com/aria.jpg",
},
{
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
avatarUrl: "https://cdn.example.com/elio.jpg",
},
],
},
});
const response = await new CharacterApi().getCharacters();
expect(httpClientMock).toHaveBeenCalledWith("/api/characters");
expect(response.items.map((character) => character.id)).toEqual([
"character_aria",
"character_elio",
]);
});
});
+1 -2
View File
@@ -24,6 +24,5 @@
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" },
"characters": { "method": "get", "path": "/api/characters" }
"feedback": { "method": "post", "path": "/api/feedback" }
}
-2
View File
@@ -99,6 +99,4 @@ export class ApiPath {
// ============ 用户反馈相关 ============
static readonly feedback = apiContract.feedback.path;
// ============ 角色相关 ============
static readonly characters = apiContract.characters.path;
}
-17
View File
@@ -1,17 +0,0 @@
import {
CharactersResponse,
CharactersResponseSchema,
} from "@/data/schemas/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export class CharacterApi {
async getCharacters(): Promise<CharactersResponse> {
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
return CharactersResponseSchema.parse(unwrap(envelope));
}
}
export const characterApi = new CharacterApi();
-1
View File
@@ -6,7 +6,6 @@ export * from "./api_path";
export * from "./api_result";
export * from "./auth_api";
export * from "./chat_api";
export * from "./character_api";
export * from "./feedback_api";
export * from "./http_client";
export * from "./metrics_api";
@@ -13,6 +13,7 @@ const PendingPaymentOrderSchema = z.object({
subscriptionType: z.enum(["vip", "topup", "tip"]),
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
returnTo: z.enum(["chat", "private-room"]).optional(),
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
createdAt: z.number(),
});