feat(characters): use local character catalog
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -99,6 +99,4 @@ export class ApiPath {
|
||||
// ============ 用户反馈相关 ============
|
||||
static readonly feedback = apiContract.feedback.path;
|
||||
|
||||
// ============ 角色相关 ============
|
||||
static readonly characters = apiContract.characters.path;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user