feat: enhance habitat appearances and item relations

Replace item tags with favorite things to unify entity tagging
Allow multiple maps, times, and weathers per habitat appearance
Make item usage optional and translate API error messages to Chinese
Add .dockerignore files for backend and frontend
This commit is contained in:
2026-04-30 06:34:23 +08:00
parent f6a40097c1
commit 7f36d6a916
11 changed files with 186 additions and 115 deletions

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.log
.env

View File

@@ -48,11 +48,6 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
name text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS item_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS recipes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
@@ -68,23 +63,28 @@ CREATE TABLE IF NOT EXISTS items (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
category_id integer NOT NULL REFERENCES item_categories(id),
usage_id integer NOT NULL REFERENCES item_usages(id),
usage_id integer REFERENCES item_usages(id),
recipe_id integer REFERENCES recipes(id),
dyeable boolean NOT NULL DEFAULT false,
dual_dyeable boolean NOT NULL DEFAULT false,
pattern_editable boolean NOT NULL DEFAULT false
);
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
DROP TABLE IF EXISTS item_item_tags;
DROP TABLE IF EXISTS item_tags;
CREATE TABLE IF NOT EXISTS item_acquisition_methods (
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
PRIMARY KEY (item_id, acquisition_method_id)
);
CREATE TABLE IF NOT EXISTS item_item_tags (
CREATE TABLE IF NOT EXISTS item_favorite_things (
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
item_tag_id integer NOT NULL REFERENCES item_tags(id),
PRIMARY KEY (item_id, item_tag_id)
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
PRIMARY KEY (item_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS recipe_materials (

View File

@@ -1,11 +1,12 @@
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
import { pool, query, queryOne } from './db.ts';
import type { PoolClient } from 'pg';
type QueryValue = string | string[] | undefined;
type QueryParams = Record<string, QueryValue>;
type DbClient = Awaited<ReturnType<typeof pool.connect>>;
type DbClient = PoolClient;
type ConfigType =
| 'skills'
@@ -14,7 +15,6 @@ type ConfigType =
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'item-tags'
| 'maps';
type ConfigDefinition = {
@@ -40,7 +40,7 @@ type PokemonPayload = {
type ItemPayload = {
name: string;
categoryId: number;
usageId: number;
usageId: number | null;
recipeId: number | null;
dyeable: boolean;
dualDyeable: boolean;
@@ -67,6 +67,11 @@ type HabitatPayload = {
}>;
};
type ValidationError = Error & { statusCode: number };
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
skills: { table: 'skills', select: 'id, name, subcategory', order: 'name, subcategory', hasSubcategory: true },
environments: { table: 'environments', select: 'id, name', order: 'name' },
@@ -74,7 +79,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'item-categories': { table: 'item_categories', select: 'id, name', order: 'name' },
'item-usages': { table: 'item_usages', select: 'id, name', order: 'name' },
'acquisition-methods': { table: 'acquisition_methods', select: 'id, name', order: 'name' },
'item-tags': { table: 'item_tags', select: 'id, name', order: 'name' },
maps: { table: 'maps', select: 'id, name', order: 'name' }
};
@@ -86,17 +90,23 @@ function optionSelect(tableName: string): Promise<Array<{ id: number; name: stri
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
}
function requirePositiveInteger(value: unknown, fieldName: string): number {
function validationError(message: string): ValidationError {
const error = new Error(message) as ValidationError;
error.statusCode = 400;
return error;
}
function requirePositiveInteger(value: unknown, message: string): number {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue <= 0) {
throw new Error(`${fieldName} is required`);
throw validationError(message);
}
return numberValue;
}
function cleanName(value: unknown): string {
function cleanName(value: unknown, message = '请输入名称'): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error('Name is required');
throw validationError(message);
}
return value.trim();
}
@@ -108,6 +118,10 @@ function cleanIds(value: unknown): number[] {
return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
}
function cleanIdValues(value: unknown): number[] {
return cleanIds(Array.isArray(value) ? value : [value]);
}
function cleanQuantities(value: unknown): IdQuantity[] {
if (!Array.isArray(value)) {
return [];
@@ -124,6 +138,11 @@ function cleanQuantities(value: unknown): IdQuantity[] {
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
}
function cleanOptions(value: unknown, allowedValues: string[]): string[] {
const values = Array.isArray(value) ? value : [value];
return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))];
}
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
@@ -169,7 +188,6 @@ export async function getOptions() {
itemCategories,
itemUsages,
acquisitionMethods,
itemTags,
maps
] = await Promise.all([
query<{ id: number; name: string; subcategory: string | null }>(
@@ -180,7 +198,6 @@ export async function getOptions() {
optionSelect('item_categories'),
optionSelect('item_usages'),
optionSelect('acquisition_methods'),
optionSelect('item_tags'),
optionSelect('maps')
]);
@@ -191,7 +208,7 @@ export async function getOptions() {
itemCategories,
itemUsages,
acquisitionMethods,
itemTags,
itemTags: favoriteThings,
maps
};
}
@@ -321,16 +338,16 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
if (skillIds.length > 2) {
throw new Error('Pokemon can have at most 2 skills');
throw validationError('特长最多选择 2 个');
}
if (favoriteThingIds.length > 6) {
throw new Error('Pokemon can have at most 6 favorite things');
throw validationError('喜欢的东西最多选择 6 个');
}
return {
id: requirePositiveInteger(payload.id, 'Pokemon ID'),
name: cleanName(payload.name),
environmentId: requirePositiveInteger(payload.environmentId, 'Environment'),
id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'),
name: cleanName(payload.name, '请输入 Pokemon 名字'),
environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'),
skillIds,
favoriteThingIds
};
@@ -457,33 +474,39 @@ export async function getHabitat(id: number) {
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
const pokemonAppearances = new Map<string, HabitatPayload['pokemonAppearances'][number]>();
for (const item of appearances) {
const row = item as Record<string, unknown>;
const pokemonId = Number(row.pokemonId);
const mapIds = cleanIdValues(row.mapIds ?? row.mapId);
const selectedTimeOfDays = cleanOptions(row.timeOfDays ?? row.timeOfDay, timeOfDays);
const selectedWeathers = cleanOptions(row.weathers ?? row.weather, weathers);
const rarity = Number(row.rarity);
if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) {
continue;
}
for (const mapId of mapIds) {
for (const timeOfDay of selectedTimeOfDays) {
for (const weather of selectedWeathers) {
pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, {
pokemonId,
mapId,
timeOfDay,
weather,
rarity
});
}
}
}
}
return {
name: cleanName(payload.name),
name: cleanName(payload.name, '请输入栖息地名字'),
recipeItems: cleanQuantities(payload.recipeItems),
pokemonAppearances: appearances
.map((item) => {
const row = item as Record<string, unknown>;
return {
pokemonId: Number(row.pokemonId),
mapId: Number(row.mapId),
timeOfDay: String(row.timeOfDay ?? ''),
weather: String(row.weather ?? ''),
rarity: Number(row.rarity)
};
})
.filter(
(item) =>
Number.isInteger(item.pokemonId) &&
item.pokemonId > 0 &&
Number.isInteger(item.mapId) &&
item.mapId > 0 &&
['早晨', '中午', '傍晚', '晚上'].includes(item.timeOfDay) &&
['晴天', '阴天', '雨天'].includes(item.weather) &&
Number.isInteger(item.rarity) &&
item.rarity >= 1 &&
item.rarity <= 3
)
pokemonAppearances: [...pokemonAppearances.values()]
};
}
@@ -548,7 +571,7 @@ const itemProjection = `
i.id,
i.name,
json_build_object('id', c.id, 'name', c.name) AS category,
json_build_object('id', u.id, 'name', u.name) AS usage,
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage,
json_build_object(
'dyeable', i.dyeable,
'dualDyeable', i.dual_dyeable,
@@ -556,13 +579,13 @@ const itemProjection = `
) AS customization,
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name)
FROM item_item_tags iit
JOIN item_tags t ON t.id = iit.item_tag_id
WHERE iit.item_id = i.id
FROM item_favorite_things ift
JOIN favorite_things t ON t.id = ift.favorite_thing_id
WHERE ift.item_id = i.id
), '[]'::json) AS tags
FROM items i
JOIN item_categories c ON c.id = i.category_id
JOIN item_usages u ON u.id = i.usage_id
LEFT JOIN item_usages u ON u.id = i.usage_id
`;
export async function listItems(paramsQuery: QueryParams) {
@@ -591,9 +614,9 @@ export async function listItems(paramsQuery: QueryParams) {
const tagFilter = sqlForRelationFilter(
tagIds,
'any',
'item_item_tags',
'item_favorite_things',
'item_id',
'item_tag_id',
'favorite_thing_id',
'i.id',
params
);
@@ -663,12 +686,15 @@ export async function getItem(id: number) {
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined
? null
: requirePositiveInteger(payload.recipeId, 'Recipe');
: requirePositiveInteger(payload.recipeId, '请选择材料单');
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
? null
: requirePositiveInteger(payload.usageId, '请选择用途');
return {
name: cleanName(payload.name),
categoryId: requirePositiveInteger(payload.categoryId, 'Category'),
usageId: requirePositiveInteger(payload.usageId, 'Usage'),
name: cleanName(payload.name, '请输入物品名字'),
categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'),
usageId,
recipeId,
dyeable: Boolean(payload.dyeable),
dualDyeable: Boolean(payload.dualDyeable),
@@ -680,7 +706,7 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise<void> {
await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]);
await client.query('DELETE FROM item_item_tags WHERE item_id = $1', [itemId]);
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]);
for (const methodId of payload.acquisitionMethodIds) {
await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
@@ -690,7 +716,10 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I
}
for (const tagId of payload.tagIds) {
await client.query('INSERT INTO item_item_tags (item_id, item_tag_id) VALUES ($1, $2)', [itemId, tagId]);
await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [
itemId,
tagId
]);
}
}
@@ -805,7 +834,7 @@ export async function getRecipe(id: number) {
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
return {
name: cleanName(payload.name),
name: cleanName(payload.name, '请输入材料单名字'),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
materials: cleanQuantities(payload.materials)
};

View File

@@ -35,6 +35,7 @@ const app = Fastify({
});
await app.register(cors, {
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
origin: process.env.FRONTEND_ORIGIN ?? true
});
@@ -42,15 +43,15 @@ app.setErrorHandler(async (error, _request, reply) => {
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
if (pgError.code === '23503') {
return reply.code(409).send({ message: 'Referenced data is missing or this item is in use' });
return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' });
}
if (pgError.code === '23505') {
return reply.code(409).send({ message: 'A record with the same value already exists' });
return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' });
}
if (pgError.code === '23514') {
return reply.code(400).send({ message: 'Invalid field value' });
return reply.code(400).send({ message: '字段值不合法' });
}
if (pgError.statusCode && pgError.statusCode < 500) {
@@ -58,7 +59,7 @@ app.setErrorHandler(async (error, _request, reply) => {
}
app.log.error(error);
return reply.code(500).send({ message: 'Server error' });
return reply.code(500).send({ message: '服务器错误' });
});
app.get('/health', async () => ({ ok: true }));