feat: 实现游戏核心功能与前端界面
- 添加游戏核心系统:战斗、任务、日志、随机数生成等 - 实现前端主界面、HUD、底部导航和各类卡片组件 - 添加用户认证系统与游戏存档持久化 - 配置项目基础架构与开发环境 - 补充文档说明与Docker部署支持
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { and, eq, lt, or } from 'drizzle-orm';
|
||||
import type { db as database } from '../../shared/database/client';
|
||||
import { saveSlotsTable, sessionsTable, usersTable } from '../../shared/database/schema';
|
||||
|
||||
type DbClient = typeof database;
|
||||
|
||||
export class AuthRepository {
|
||||
constructor(private readonly db: DbClient) {}
|
||||
|
||||
findUserByIdentifier(identifier: string) {
|
||||
return this.db.query.usersTable.findFirst({
|
||||
where: or(eq(usersTable.email, identifier), eq(usersTable.username, identifier)),
|
||||
});
|
||||
}
|
||||
|
||||
findUserById(userId: string) {
|
||||
return this.db.query.usersTable.findFirst({
|
||||
where: eq(usersTable.id, userId),
|
||||
});
|
||||
}
|
||||
|
||||
createUser(user: typeof usersTable.$inferInsert) {
|
||||
this.db.insert(usersTable).values(user).run();
|
||||
return user;
|
||||
}
|
||||
|
||||
createSession(session: typeof sessionsTable.$inferInsert) {
|
||||
this.db.insert(sessionsTable).values(session).run();
|
||||
return session;
|
||||
}
|
||||
|
||||
findSessionByTokenHash(tokenHash: string) {
|
||||
return this.db.query.sessionsTable.findFirst({
|
||||
where: eq(sessionsTable.tokenHash, tokenHash),
|
||||
});
|
||||
}
|
||||
|
||||
deleteSessionByTokenHash(tokenHash: string) {
|
||||
this.db.delete(sessionsTable).where(eq(sessionsTable.tokenHash, tokenHash)).run();
|
||||
}
|
||||
|
||||
deleteExpiredSessions(now: number) {
|
||||
this.db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, now)).run();
|
||||
}
|
||||
|
||||
getMainSaveSlot(userId: string) {
|
||||
return this.db.query.saveSlotsTable.findFirst({
|
||||
where: and(eq(saveSlotsTable.userId, userId), eq(saveSlotsTable.slotKey, 'main')),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { loginSchema, registerSchema } from './auth.schemas';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const registerAuthRoutes = (app: FastifyInstance, authService: AuthService) => {
|
||||
app.get('/api/auth/me', async (request) => {
|
||||
const user = await authService.getUserFromRequest(request);
|
||||
return { user };
|
||||
});
|
||||
|
||||
app.post('/api/auth/register', async (request, reply) => {
|
||||
const input = registerSchema.parse(request.body);
|
||||
const user = await authService.register(input, reply);
|
||||
return { user };
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (request, reply) => {
|
||||
const input = loginSchema.parse(request.body);
|
||||
const user = await authService.login(input, reply);
|
||||
return { user };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (request, reply) => {
|
||||
await authService.logout(request, reply);
|
||||
return { ok: true };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().trim().min(3).max(24),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
identifier: z.string().trim().min(3),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AppError } from '../../shared/errors';
|
||||
import { env } from '../../shared/env';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import type { LoginInput, RegisterInput } from './auth.schemas';
|
||||
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 14;
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const normalizeIdentifier = (value: string) => value.trim().toLowerCase();
|
||||
const hashSessionToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
export class AuthService {
|
||||
constructor(private readonly repository: AuthRepository) {}
|
||||
|
||||
private sanitizeUser(user: { id: string; email: string; username: string }): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
};
|
||||
}
|
||||
|
||||
private issueSession(reply: FastifyReply, userId: string) {
|
||||
this.repository.deleteExpiredSessions(Date.now());
|
||||
|
||||
const rawToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = hashSessionToken(rawToken);
|
||||
const now = Date.now();
|
||||
const session = {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt: now + SESSION_TTL_MS,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
this.repository.createSession(session);
|
||||
reply.setCookie(env.sessionCookieName, rawToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
async register(input: RegisterInput, reply: FastifyReply) {
|
||||
const email = normalizeIdentifier(input.email);
|
||||
const username = input.username.trim();
|
||||
|
||||
const existing = await this.repository.findUserByIdentifier(email);
|
||||
if (existing) {
|
||||
throw new AppError(409, 'AUTH_EXISTS', '该邮箱或用户名已经被使用。');
|
||||
}
|
||||
|
||||
const byUsername = await this.repository.findUserByIdentifier(username);
|
||||
if (byUsername) {
|
||||
throw new AppError(409, 'AUTH_EXISTS', '该邮箱或用户名已经被使用。');
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: randomUUID(),
|
||||
email,
|
||||
username,
|
||||
passwordHash: await bcrypt.hash(input.password, 10),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.repository.createUser(user);
|
||||
this.issueSession(reply, user.id);
|
||||
return this.sanitizeUser(user);
|
||||
}
|
||||
|
||||
async login(input: LoginInput, reply: FastifyReply) {
|
||||
const identifier = input.identifier.includes('@')
|
||||
? normalizeIdentifier(input.identifier)
|
||||
: input.identifier.trim();
|
||||
const user = await this.repository.findUserByIdentifier(identifier);
|
||||
if (!user) {
|
||||
throw new AppError(401, 'AUTH_INVALID', '账号或密码不正确。');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(input.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new AppError(401, 'AUTH_INVALID', '账号或密码不正确。');
|
||||
}
|
||||
|
||||
this.issueSession(reply, user.id);
|
||||
return this.sanitizeUser(user);
|
||||
}
|
||||
|
||||
async getUserFromRequest(request: FastifyRequest) {
|
||||
const rawToken = request.cookies[env.sessionCookieName];
|
||||
if (!rawToken) return null;
|
||||
|
||||
const session = await this.repository.findSessionByTokenHash(hashSessionToken(rawToken));
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt < Date.now()) {
|
||||
this.repository.deleteSessionByTokenHash(session.tokenHash);
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.repository.findUserById(session.userId);
|
||||
return user ? this.sanitizeUser(user) : null;
|
||||
}
|
||||
|
||||
async requireUser(request: FastifyRequest) {
|
||||
const user = await this.getUserFromRequest(request);
|
||||
if (!user) {
|
||||
throw new AppError(401, 'AUTH_REQUIRED', '请先登录。');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async logout(request: FastifyRequest, reply: FastifyReply) {
|
||||
const rawToken = request.cookies[env.sessionCookieName];
|
||||
if (rawToken) {
|
||||
this.repository.deleteSessionByTokenHash(hashSessionToken(rawToken));
|
||||
}
|
||||
reply.clearCookie(env.sessionCookieName, { path: '/' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { db as database } from '../../shared/database/client';
|
||||
import { saveSlotsTable } from '../../shared/database/schema';
|
||||
|
||||
type DbClient = typeof database;
|
||||
|
||||
export class GameRepository {
|
||||
constructor(private readonly db: DbClient) {}
|
||||
|
||||
getMainSlot(userId: string) {
|
||||
return this.db.query.saveSlotsTable.findFirst({
|
||||
where: and(eq(saveSlotsTable.userId, userId), eq(saveSlotsTable.slotKey, 'main')),
|
||||
});
|
||||
}
|
||||
|
||||
async upsertMainSlot(userId: string, stateJson: string, label: string) {
|
||||
const existing = await this.getMainSlot(userId);
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
this.db
|
||||
.update(saveSlotsTable)
|
||||
.set({
|
||||
stateJson,
|
||||
label,
|
||||
revision: existing.revision + 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(saveSlotsTable.id, existing.id))
|
||||
.run();
|
||||
return await this.getMainSlot(userId);
|
||||
}
|
||||
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
slotKey: 'main',
|
||||
label,
|
||||
stateJson,
|
||||
revision: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.insert(saveSlotsTable).values(created).run();
|
||||
return await this.getMainSlot(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { createGameSchema, gameActionSchema } from './game.schemas';
|
||||
import { GameService } from './game.service';
|
||||
|
||||
export const registerGameRoutes = (
|
||||
app: FastifyInstance,
|
||||
authService: AuthService,
|
||||
gameService: GameService,
|
||||
) => {
|
||||
app.get('/api/game/state', async (request) => {
|
||||
const user = await authService.requireUser(request);
|
||||
return await gameService.getState(user.id);
|
||||
});
|
||||
|
||||
app.post('/api/game/new', async (request) => {
|
||||
const user = await authService.requireUser(request);
|
||||
const input = createGameSchema.parse(request.body);
|
||||
return { view: await gameService.createGame(user.id, input) };
|
||||
});
|
||||
|
||||
app.post('/api/game/action', async (request) => {
|
||||
const user = await authService.requireUser(request);
|
||||
const action = gameActionSchema.parse(request.body);
|
||||
return { view: await gameService.applyAction(user.id, action) };
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createGameSchema = z.object({
|
||||
playerName: z.string().trim().min(2).max(24),
|
||||
});
|
||||
|
||||
export const gameActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('travel'), toPlaceId: z.string().min(1) }),
|
||||
z.object({ type: z.literal('perform-action'), actionId: z.string().min(1) }),
|
||||
z.object({ type: z.literal('select-event-option'), optionId: z.string().min(1) }),
|
||||
z.object({
|
||||
type: z.literal('combat'),
|
||||
move: z.enum(['attack', 'defend', 'advance', 'retreat', 'escape']),
|
||||
}),
|
||||
z.object({ type: z.literal('craft'), recipeId: z.string().min(1) }),
|
||||
z.object({ type: z.literal('use-item'), itemId: z.string().min(1) }),
|
||||
z.object({ type: z.literal('equip-item'), itemId: z.string().min(1) }),
|
||||
z.object({ type: z.literal('unequip-item'), slot: z.enum(['weapon', 'body', 'tool']) }),
|
||||
z.object({ type: z.literal('trade'), offerId: z.string().min(1) }),
|
||||
z.object({ type: z.literal('rest'), minutes: z.number().int().min(10).max(240) }),
|
||||
]);
|
||||
|
||||
export type CreateGameInput = z.infer<typeof createGameSchema>;
|
||||
export type GameActionInput = z.infer<typeof gameActionSchema>;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
applyGameAction,
|
||||
buildGameView,
|
||||
createNewGameState,
|
||||
type GameState,
|
||||
} from '@tinywaste/game-core';
|
||||
import { gameContent } from '@tinywaste/content';
|
||||
import { AppError } from '../../shared/errors';
|
||||
import { GameRepository } from './game.repository';
|
||||
import type { CreateGameInput, GameActionInput } from './game.schemas';
|
||||
|
||||
export class GameService {
|
||||
constructor(private readonly repository: GameRepository) {}
|
||||
|
||||
async getState(userId: string) {
|
||||
const slot = await this.repository.getMainSlot(userId);
|
||||
if (!slot) {
|
||||
return { hasSave: false as const, view: null };
|
||||
}
|
||||
|
||||
const state = JSON.parse(slot.stateJson) as GameState;
|
||||
return {
|
||||
hasSave: true as const,
|
||||
view: buildGameView(state, gameContent),
|
||||
};
|
||||
}
|
||||
|
||||
async createGame(userId: string, input: CreateGameInput) {
|
||||
const state = createNewGameState(gameContent, input.playerName.trim());
|
||||
await this.repository.upsertMainSlot(
|
||||
userId,
|
||||
JSON.stringify(state),
|
||||
`${input.playerName.trim()} 的进度`,
|
||||
);
|
||||
return buildGameView(state, gameContent);
|
||||
}
|
||||
|
||||
async applyAction(userId: string, action: GameActionInput) {
|
||||
const slot = await this.repository.getMainSlot(userId);
|
||||
if (!slot) {
|
||||
throw new AppError(404, 'GAME_NOT_FOUND', '还没有创建游戏进度。');
|
||||
}
|
||||
|
||||
const state = JSON.parse(slot.stateJson) as GameState;
|
||||
const result = applyGameAction(state, gameContent, action);
|
||||
await this.repository.upsertMainSlot(userId, JSON.stringify(result.state), slot.label);
|
||||
return buildGameView(result.state, gameContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { ZodError } from 'zod';
|
||||
import { db } from './shared/database/client';
|
||||
import { env } from './shared/env';
|
||||
import { formatZodError, isAppError } from './shared/errors';
|
||||
import { AuthRepository } from './features/auth/auth.repository';
|
||||
import { AuthService } from './features/auth/auth.service';
|
||||
import { registerAuthRoutes } from './features/auth/auth.routes';
|
||||
import { GameRepository } from './features/game/game.repository';
|
||||
import { GameService } from './features/game/game.service';
|
||||
import { registerGameRoutes } from './features/game/game.routes';
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cookie, {
|
||||
secret: env.sessionSecret,
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: env.webOrigin,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const authRepository = new AuthRepository(db);
|
||||
const authService = new AuthService(authRepository);
|
||||
const gameRepository = new GameRepository(db);
|
||||
const gameService = new GameService(gameRepository);
|
||||
|
||||
app.get('/api/health', async () => ({
|
||||
ok: true,
|
||||
service: 'tinywaste-api',
|
||||
}));
|
||||
|
||||
registerAuthRoutes(app, authService);
|
||||
registerGameRoutes(app, authService, gameService);
|
||||
|
||||
const webDistAvailable = existsSync(env.webDistPath);
|
||||
|
||||
if (webDistAvailable) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: env.webDistPath,
|
||||
prefix: '/',
|
||||
maxAge: '30d',
|
||||
immutable: true,
|
||||
});
|
||||
|
||||
app.get('/', async (_request, reply) =>
|
||||
reply.sendFile('index.html', {
|
||||
maxAge: 0,
|
||||
immutable: false,
|
||||
}),
|
||||
);
|
||||
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
const acceptsHtml = request.headers.accept?.includes('text/html');
|
||||
|
||||
if (request.method === 'GET' && acceptsHtml && !request.url.startsWith('/api/')) {
|
||||
return reply.sendFile('index.html', {
|
||||
maxAge: 0,
|
||||
immutable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(404).send({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: '请求的资源不存在。',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
if (isAppError(error)) {
|
||||
reply.status(error.statusCode).send({
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: formatZodError(error),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
reply.status(500).send({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '服务器内部错误,请稍后再试。',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await app.listen({ port: env.port, host: '0.0.0.0' });
|
||||
app.log.info(`API running on http://localhost:${env.port}`);
|
||||
if (webDistAvailable) {
|
||||
app.log.info(`Web bundle served from ${env.webDistPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
await start();
|
||||
@@ -0,0 +1,11 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { env } from '../env';
|
||||
import * as schema from './schema';
|
||||
|
||||
const sqlite = new Database(env.databasePath);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite, schema };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const usersTable = sqliteTable(
|
||||
'users',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
username: text('username').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
usersEmailUnique: uniqueIndex('users_email_unique').on(table.email),
|
||||
usersUsernameUnique: uniqueIndex('users_username_unique').on(table.username),
|
||||
}),
|
||||
);
|
||||
|
||||
export const sessionsTable = sqliteTable(
|
||||
'sessions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: 'cascade' }),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'number' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
sessionsTokenHashUnique: uniqueIndex('sessions_token_hash_unique').on(table.tokenHash),
|
||||
}),
|
||||
);
|
||||
|
||||
export const saveSlotsTable = sqliteTable(
|
||||
'save_slots',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: 'cascade' }),
|
||||
slotKey: text('slot_key').notNull(),
|
||||
label: text('label').notNull(),
|
||||
stateJson: text('state_json').notNull(),
|
||||
revision: integer('revision', { mode: 'number' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
saveSlotsUserSlotUnique: uniqueIndex('save_slots_user_slot_unique').on(table.userId, table.slotKey),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
loadDotenv({
|
||||
path: [resolve(process.cwd(), '.env'), resolve(process.cwd(), '../../.env')],
|
||||
});
|
||||
|
||||
const envSchema = z.object({
|
||||
API_PORT: z.coerce.number().int().positive().default(3001),
|
||||
WEB_ORIGIN: z.string().url().default('http://localhost:5173'),
|
||||
WEB_DIST_DIR: z.string().min(1).default('../web/dist'),
|
||||
SESSION_COOKIE_NAME: z.string().min(3).default('tinywaste_session'),
|
||||
SESSION_SECRET: z.string().min(16),
|
||||
DATABASE_URL: z.string().min(1).default('./data/tinywaste.db'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid environment: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const databasePath = resolve(process.cwd(), parsed.data.DATABASE_URL);
|
||||
const webDistPath = resolve(process.cwd(), parsed.data.WEB_DIST_DIR);
|
||||
mkdirSync(dirname(databasePath), { recursive: true });
|
||||
|
||||
export const env = {
|
||||
port: parsed.data.API_PORT,
|
||||
webOrigin: parsed.data.WEB_ORIGIN,
|
||||
webDistPath,
|
||||
sessionCookieName: parsed.data.SESSION_COOKIE_NAME,
|
||||
sessionSecret: parsed.data.SESSION_SECRET,
|
||||
databasePath,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loginSchema, registerSchema } from '../features/auth/auth.schemas';
|
||||
import { formatZodError } from './errors';
|
||||
|
||||
describe('formatZodError', () => {
|
||||
it('returns a friendly message for short passwords', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
identifier: 'tester',
|
||||
password: '123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
|
||||
expect(formatZodError(result.error)).toBe('密码 至少需要 8 个字符。');
|
||||
});
|
||||
|
||||
it('returns a friendly message for invalid email input', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'not-an-email',
|
||||
username: 'tester',
|
||||
password: '12345678',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
|
||||
expect(formatZodError(result.error)).toBe('请输入有效的邮箱地址。');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export const isAppError = (value: unknown): value is AppError => value instanceof AppError;
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
identifier: '邮箱或用户名',
|
||||
password: '密码',
|
||||
playerName: '幸存者代号',
|
||||
actionId: '动作',
|
||||
toPlaceId: '目标地点',
|
||||
optionId: '事件选项',
|
||||
recipeId: '配方',
|
||||
itemId: '物品',
|
||||
slot: '装备槽位',
|
||||
offerId: '交易项',
|
||||
minutes: '休息时长',
|
||||
move: '战斗动作',
|
||||
};
|
||||
|
||||
const resolveFieldLabel = (path: Array<string | number | symbol>) => {
|
||||
if (path.length === 0) return '提交内容';
|
||||
const key = String(path[path.length - 1]);
|
||||
return FIELD_LABELS[key] ?? key;
|
||||
};
|
||||
|
||||
export const formatZodError = (error: ZodError) => {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) {
|
||||
return '请求参数不合法。';
|
||||
}
|
||||
|
||||
const fieldLabel = resolveFieldLabel(issue.path);
|
||||
|
||||
switch (issue.code) {
|
||||
case 'invalid_type':
|
||||
return `${fieldLabel} 类型不正确。`;
|
||||
case 'too_small':
|
||||
if (issue.origin === 'string') {
|
||||
return `${fieldLabel} 至少需要 ${issue.minimum} 个字符。`;
|
||||
}
|
||||
return `${fieldLabel} 不能小于 ${issue.minimum}。`;
|
||||
case 'too_big':
|
||||
if (issue.origin === 'string') {
|
||||
return `${fieldLabel} 不能超过 ${issue.maximum} 个字符。`;
|
||||
}
|
||||
return `${fieldLabel} 不能大于 ${issue.maximum}。`;
|
||||
case 'invalid_format':
|
||||
if (issue.format === 'email') {
|
||||
return '请输入有效的邮箱地址。';
|
||||
}
|
||||
return `${fieldLabel} 格式不正确。`;
|
||||
case 'invalid_value':
|
||||
return `${fieldLabel} 的值不在允许范围内。`;
|
||||
default:
|
||||
return issue.message && issue.message !== 'Invalid input'
|
||||
? `${fieldLabel}:${issue.message}`
|
||||
: `${fieldLabel} 不合法。`;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user