diff --git a/DESIGN.md b/DESIGN.md index 7ed5555..0681db8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -129,3 +129,11 @@ Eg: 名称:乱撒,二级分类:棉花 - 登录后可获取当前用户信息 - 用户可退出登录 - API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据 + +## Community 编辑 + +- 所有人都可浏览 Wiki 内容 +- 已注册并完成邮箱验证的用户都可编辑 Wiki 内容 +- 每次创建、修改、删除 Wiki 内容都需要记录编辑者 +- Wiki 内容展示最后编辑者和最后编辑时间 +- 编辑署名只展示必要用户信息,不暴露邮箱、token、hash 或内部元数据 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 80d2e29..aaf3f89 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -186,3 +186,73 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon ( rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3), PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather) ); + +ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE items ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +CREATE TABLE IF NOT EXISTS wiki_edit_logs ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + entity_type text NOT NULL, + entity_id integer NOT NULL, + action text NOT NULL CHECK (action IN ('create', 'update', 'delete')), + user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx + ON wiki_edit_logs(entity_type, entity_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx + ON wiki_edit_logs(user_id); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index a0f67a3..a52aebe 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -67,6 +67,7 @@ type HabitatPayload = { }; type ValidationError = Error & { statusCode: number }; +type EditAction = 'create' | 'update' | 'delete'; const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const weathers = ['晴天', '阴天', '雨天']; @@ -89,6 +90,39 @@ function optionSelect(tableName: string): Promise `c.${column}`) + .join(', '); +} + function validationError(message: string): ValidationError { const error = new Error(message) as ValidationError; error.statusCode = 400; @@ -158,10 +192,27 @@ async function withTransaction(callback: (client: DbClient) => Promise): P } } +async function recordEditLog( + client: DbClient, + entityType: string, + entityId: number, + action: EditAction, + userId: number +): Promise { + await client.query( + ` + INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id) + VALUES ($1, $2, $3, $4) + `, + [entityType, entityId, action, userId] + ); +} + const pokemonProjection = ` SELECT p.id, p.name, + ${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')}, json_build_object('id', e.id, 'name', e.name) AS environment, COALESCE(( SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'subcategory', s.subcategory) ORDER BY s.name, s.subcategory) @@ -177,6 +228,7 @@ const pokemonProjection = ` ), '[]'::json) AS favorite_things FROM pokemon p JOIN environments e ON e.id = p.environment_id + ${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')} `; export async function getOptions() { @@ -218,43 +270,107 @@ export function isConfigType(type: string): type is ConfigType { export async function listConfig(type: ConfigType) { const definition = configDefinitions[type]; - return query(`SELECT ${definition.select} FROM ${definition.table} ORDER BY ${definition.order}`); + return query( + ` + SELECT ${configSelect(definition)}, ${auditSelect('c')} + FROM ${definition.table} c + ${auditJoins('c')} + ORDER BY ${configOrder(definition)} + ` + ); } -export async function createConfig(type: ConfigType, payload: Record) { +async function getConfigById(type: ConfigType, id: number) { + const definition = configDefinitions[type]; + return queryOne( + ` + SELECT ${configSelect(definition)}, ${auditSelect('c')} + FROM ${definition.table} c + ${auditJoins('c')} + WHERE c.id = $1 + `, + [id] + ); +} + +export async function createConfig(type: ConfigType, payload: Record, userId: number) { const definition = configDefinitions[type]; const name = cleanName(payload.name); const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null; - if (definition.hasSubcategory) { - return queryOne( - `INSERT INTO ${definition.table} (name, subcategory) VALUES ($1, $2) RETURNING ${definition.select}`, - [name, subcategory] - ); - } + const id = await withTransaction(async (client) => { + const result = definition.hasSubcategory + ? await client.query<{ id: number }>( + ` + INSERT INTO ${definition.table} (name, subcategory, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $3, $3) + RETURNING id + `, + [name, subcategory, userId] + ) + : await client.query<{ id: number }>( + ` + INSERT INTO ${definition.table} (name, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $2) + RETURNING id + `, + [name, userId] + ); - return queryOne(`INSERT INTO ${definition.table} (name) VALUES ($1) RETURNING ${definition.select}`, [name]); + const createdId = result.rows[0].id; + await recordEditLog(client, type, createdId, 'create', userId); + return createdId; + }); + + return getConfigById(type, id); } -export async function updateConfig(type: ConfigType, id: number, payload: Record) { +export async function updateConfig(type: ConfigType, id: number, payload: Record, userId: number) { const definition = configDefinitions[type]; const name = cleanName(payload.name); const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null; - if (definition.hasSubcategory) { - return queryOne( - `UPDATE ${definition.table} SET name = $1, subcategory = $2 WHERE id = $3 RETURNING ${definition.select}`, - [name, subcategory, id] - ); - } + const updated = await withTransaction(async (client) => { + const result = definition.hasSubcategory + ? await client.query( + ` + UPDATE ${definition.table} + SET name = $1, subcategory = $2, updated_by_user_id = $3, updated_at = now() + WHERE id = $4 + `, + [name, subcategory, userId, id] + ) + : await client.query( + ` + UPDATE ${definition.table} + SET name = $1, updated_by_user_id = $2, updated_at = now() + WHERE id = $3 + `, + [name, userId, id] + ); - return queryOne(`UPDATE ${definition.table} SET name = $1 WHERE id = $2 RETURNING ${definition.select}`, [name, id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, type, id, 'update', userId); + return true; + }); + + return updated ? getConfigById(type, id) : null; } -export async function deleteConfig(type: ConfigType, id: number) { +export async function deleteConfig(type: ConfigType, id: number, userId: number) { const definition = configDefinitions[type]; - const result = await pool.query(`DELETE FROM ${definition.table} WHERE id = $1`, [id]); - return (result.rowCount ?? 0) > 0; + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, type, id, 'delete', userId); + return true; + }); } export async function listPokemon(paramsQuery: QueryParams) { @@ -368,42 +484,56 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl } } -export async function createPokemon(payload: Record) { +export async function createPokemon(payload: Record, userId: number) { const cleanPayload = cleanPokemonPayload(payload); const id = await withTransaction(async (client) => { - await client.query('INSERT INTO pokemon (id, name, environment_id) VALUES ($1, $2, $3)', [ - cleanPayload.id, - cleanPayload.name, - cleanPayload.environmentId - ]); + await client.query( + ` + INSERT INTO pokemon (id, name, environment_id, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $3, $4, $4) + `, + [cleanPayload.id, cleanPayload.name, cleanPayload.environmentId, userId] + ); await replacePokemonRelations(client, cleanPayload.id, cleanPayload); + await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId); return cleanPayload.id; }); return getPokemon(id); } -export async function updatePokemon(id: number, payload: Record) { +export async function updatePokemon(id: number, payload: Record, userId: number) { const cleanPayload = cleanPokemonPayload({ ...payload, id }); const updated = await withTransaction(async (client) => { - const result = await client.query('UPDATE pokemon SET name = $1, environment_id = $2 WHERE id = $3', [ - cleanPayload.name, - cleanPayload.environmentId, - id - ]); + const result = await client.query( + ` + UPDATE pokemon + SET name = $1, environment_id = $2, updated_by_user_id = $3, updated_at = now() + WHERE id = $4 + `, + [cleanPayload.name, cleanPayload.environmentId, userId, id] + ); if (result.rowCount === 0) { return false; } await replacePokemonRelations(client, id, cleanPayload); + await recordEditLog(client, 'pokemon', id, 'update', userId); return true; }); return updated ? getPokemon(id) : null; } -export async function deletePokemon(id: number) { - const result = await pool.query('DELETE FROM pokemon WHERE id = $1', [id]); - return (result.rowCount ?? 0) > 0; +export async function deletePokemon(id: number, userId: number) { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>('DELETE FROM pokemon WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, 'pokemon', id, 'delete', userId); + return true; + }); } export async function listHabitats() { @@ -411,6 +541,7 @@ export async function listHabitats() { SELECT h.id, h.name, + ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, COALESCE(( SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name) FROM habitat_recipe_items hri @@ -424,6 +555,7 @@ export async function listHabitats() { WHERE hp.habitat_id = h.id ), '[]'::json) AS pokemon FROM habitats h + ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')} ORDER BY h.name `); } @@ -434,6 +566,7 @@ export async function getHabitat(id: number) { SELECT h.id, h.name, + ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, COALESCE(( SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name) FROM habitat_recipe_items hri @@ -441,6 +574,7 @@ export async function getHabitat(id: number) { WHERE hri.habitat_id = h.id ), '[]'::json) AS recipe FROM habitats h + ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')} WHERE h.id = $1 `, [id] @@ -532,43 +666,61 @@ async function replaceHabitatRelations(client: DbClient, habitatId: number, payl } } -export async function createHabitat(payload: Record) { +export async function createHabitat(payload: Record, userId: number) { const cleanPayload = cleanHabitatPayload(payload); const id = await withTransaction(async (client) => { - const result = await client.query<{ id: number }>('INSERT INTO habitats (name) VALUES ($1) RETURNING id', [ - cleanPayload.name - ]); + const result = await client.query<{ id: number }>( + ` + INSERT INTO habitats (name, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $2) + RETURNING id + `, + [cleanPayload.name, userId] + ); const habitatId = result.rows[0].id; await replaceHabitatRelations(client, habitatId, cleanPayload); + await recordEditLog(client, 'habitats', habitatId, 'create', userId); return habitatId; }); return getHabitat(id); } -export async function updateHabitat(id: number, payload: Record) { +export async function updateHabitat(id: number, payload: Record, userId: number) { const cleanPayload = cleanHabitatPayload(payload); const updated = await withTransaction(async (client) => { - const result = await client.query('UPDATE habitats SET name = $1 WHERE id = $2', [cleanPayload.name, id]); + const result = await client.query( + 'UPDATE habitats SET name = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', + [cleanPayload.name, userId, id] + ); if (result.rowCount === 0) { return false; } await replaceHabitatRelations(client, id, cleanPayload); + await recordEditLog(client, 'habitats', id, 'update', userId); return true; }); return updated ? getHabitat(id) : null; } -export async function deleteHabitat(id: number) { - const result = await pool.query('DELETE FROM habitats WHERE id = $1', [id]); - return (result.rowCount ?? 0) > 0; +export async function deleteHabitat(id: number, userId: number) { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>('DELETE FROM habitats WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, 'habitats', id, 'delete', userId); + return true; + }); } const itemProjection = ` SELECT i.id, i.name, + ${auditSelect('i', 'item_created_user', 'item_updated_user')}, json_build_object('id', c.id, 'name', c.name) AS category, CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage, json_build_object( @@ -585,6 +737,7 @@ const itemProjection = ` FROM items i JOIN item_categories c ON c.id = i.category_id LEFT JOIN item_usages u ON u.id = i.usage_id + ${auditJoins('i', 'item_created_user', 'item_updated_user')} `; export async function listItems(paramsQuery: QueryParams) { @@ -649,6 +802,7 @@ export async function getItem(id: number) { SELECT r.id, result_item.name, + ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, COALESCE(( SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) FROM recipe_acquisition_methods ram @@ -664,6 +818,7 @@ export async function getItem(id: number) { json_build_object('id', result_item.id, 'name', result_item.name) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id + ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} WHERE r.item_id = $1 `, [id] @@ -719,14 +874,23 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I } } -export async function createItem(payload: Record) { +export async function createItem(payload: Record, userId: number) { const cleanPayload = cleanItemPayload(payload); const id = await withTransaction(async (client) => { const result = await client.query<{ id: number }>( ` - INSERT INTO items (name, category_id, usage_id, dyeable, dual_dyeable, pattern_editable) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO items ( + name, + category_id, + usage_id, + dyeable, + dual_dyeable, + pattern_editable, + created_by_user_id, + updated_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING id `, [ @@ -735,17 +899,19 @@ export async function createItem(payload: Record) { cleanPayload.usageId, cleanPayload.dyeable, cleanPayload.dualDyeable, - cleanPayload.patternEditable + cleanPayload.patternEditable, + userId ] ); const itemId = result.rows[0].id; await replaceItemRelations(client, itemId, cleanPayload); + await recordEditLog(client, 'items', itemId, 'create', userId); return itemId; }); return getItem(id); } -export async function updateItem(id: number, payload: Record) { +export async function updateItem(id: number, payload: Record, userId: number) { const cleanPayload = cleanItemPayload(payload); const updated = await withTransaction(async (client) => { @@ -757,8 +923,10 @@ export async function updateItem(id: number, payload: Record) { usage_id = $3, dyeable = $4, dual_dyeable = $5, - pattern_editable = $6 - WHERE id = $7 + pattern_editable = $6, + updated_by_user_id = $7, + updated_at = now() + WHERE id = $8 `, [ cleanPayload.name, @@ -767,6 +935,7 @@ export async function updateItem(id: number, payload: Record) { cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable, + userId, id ] ); @@ -774,14 +943,22 @@ export async function updateItem(id: number, payload: Record) { return false; } await replaceItemRelations(client, id, cleanPayload); + await recordEditLog(client, 'items', id, 'update', userId); return true; }); return updated ? getItem(id) : null; } -export async function deleteItem(id: number) { - const result = await pool.query('DELETE FROM items WHERE id = $1', [id]); - return (result.rowCount ?? 0) > 0; +export async function deleteItem(id: number, userId: number) { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, 'items', id, 'delete', userId); + return true; + }); } export async function listRecipes() { @@ -789,6 +966,7 @@ export async function listRecipes() { SELECT r.id, result_item.name, + ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, COALESCE(( SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) FROM recipe_materials rm @@ -797,6 +975,7 @@ export async function listRecipes() { ), '[]'::json) AS materials FROM recipes r JOIN items result_item ON result_item.id = r.item_id + ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} ORDER BY result_item.name `); } @@ -807,6 +986,7 @@ export async function getRecipe(id: number) { SELECT r.id, result_item.name, + ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, COALESCE(( SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) FROM recipe_acquisition_methods ram @@ -822,6 +1002,7 @@ export async function getRecipe(id: number) { json_build_object('id', result_item.id, 'name', result_item.name) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id + ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} WHERE r.id = $1 `, [id] @@ -863,37 +1044,54 @@ async function ensureItemExists(client: DbClient, itemId: number): Promise } } -export async function createRecipe(payload: Record) { +export async function createRecipe(payload: Record, userId: number) { const cleanPayload = cleanRecipePayload(payload); const id = await withTransaction(async (client) => { await ensureItemExists(client, cleanPayload.itemId); - const result = await client.query<{ id: number }>('INSERT INTO recipes (item_id) VALUES ($1) RETURNING id', [ - cleanPayload.itemId - ]); + const result = await client.query<{ id: number }>( + ` + INSERT INTO recipes (item_id, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $2) + RETURNING id + `, + [cleanPayload.itemId, userId] + ); const recipeId = result.rows[0].id; await replaceRecipeRelations(client, recipeId, cleanPayload); + await recordEditLog(client, 'recipes', recipeId, 'create', userId); return recipeId; }); return getRecipe(id); } -export async function updateRecipe(id: number, payload: Record) { +export async function updateRecipe(id: number, payload: Record, userId: number) { const cleanPayload = cleanRecipePayload(payload); const updated = await withTransaction(async (client) => { await ensureItemExists(client, cleanPayload.itemId); - const result = await client.query('UPDATE recipes SET item_id = $1 WHERE id = $2', [cleanPayload.itemId, id]); + const result = await client.query( + 'UPDATE recipes SET item_id = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', + [cleanPayload.itemId, userId, id] + ); if (result.rowCount === 0) { return false; } await replaceRecipeRelations(client, id, cleanPayload); + await recordEditLog(client, 'recipes', id, 'update', userId); return true; }); return updated ? getRecipe(id) : null; } -export async function deleteRecipe(id: number) { - const result = await pool.query('DELETE FROM recipes WHERE id = $1', [id]); - return (result.rowCount ?? 0) > 0; +export async function deleteRecipe(id: number, userId: number) { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>('DELETE FROM recipes WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, 'recipes', id, 'delete', userId); + return true; + }); } diff --git a/backend/src/server.ts b/backend/src/server.ts index 01452e9..c96a60a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,7 @@ import cors from '@fastify/cors'; import Fastify from 'fastify'; -import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail } from './auth.ts'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts'; import { initializeDatabase, pool } from './db.ts'; import { createConfig, @@ -71,6 +72,23 @@ function getBearerToken(authorization: string | undefined): string | null { return scheme === 'Bearer' && token ? token : null; } +async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise { + const token = getBearerToken(request.headers.authorization); + const user = token ? await getUserBySessionToken(token) : null; + + if (!user) { + reply.code(401).send({ message: '请先登录' }); + return null; + } + + if (!user.emailVerified) { + reply.code(403).send({ message: '请先完成邮箱验证' }); + return null; + } + + return user; +} + app.post('/api/auth/register', async (request, reply) => reply.code(201).send(await registerUser(request.body as Record)) ); @@ -114,11 +132,18 @@ app.get('/api/pokemon/:id', async (request, reply) => { return pokemon; }); -app.post('/api/pokemon', async (request, reply) => reply.code(201).send(await createPokemon(request.body as Record))); +app.post('/api/pokemon', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createPokemon(request.body as Record, user.id)) : undefined; +}); app.put('/api/pokemon/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const pokemon = await updatePokemon(Number(id), request.body as Record); + const pokemon = await updatePokemon(Number(id), request.body as Record, user.id); if (!pokemon) { return reply.code(404).send({ message: 'Not found' }); @@ -128,8 +153,12 @@ app.put('/api/pokemon/:id', async (request, reply) => { }); app.delete('/api/pokemon/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const deleted = await deletePokemon(Number(id)); + const deleted = await deletePokemon(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); @@ -146,11 +175,18 @@ app.get('/api/habitats/:id', async (request, reply) => { return habitat; }); -app.post('/api/habitats', async (request, reply) => reply.code(201).send(await createHabitat(request.body as Record))); +app.post('/api/habitats', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createHabitat(request.body as Record, user.id)) : undefined; +}); app.put('/api/habitats/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const habitat = await updateHabitat(Number(id), request.body as Record); + const habitat = await updateHabitat(Number(id), request.body as Record, user.id); if (!habitat) { return reply.code(404).send({ message: 'Not found' }); @@ -160,8 +196,12 @@ app.put('/api/habitats/:id', async (request, reply) => { }); app.delete('/api/habitats/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const deleted = await deleteHabitat(Number(id)); + const deleted = await deleteHabitat(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); @@ -178,11 +218,18 @@ app.get('/api/items/:id', async (request, reply) => { return item; }); -app.post('/api/items', async (request, reply) => reply.code(201).send(await createItem(request.body as Record))); +app.post('/api/items', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createItem(request.body as Record, user.id)) : undefined; +}); app.put('/api/items/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const item = await updateItem(Number(id), request.body as Record); + const item = await updateItem(Number(id), request.body as Record, user.id); if (!item) { return reply.code(404).send({ message: 'Not found' }); @@ -192,8 +239,12 @@ app.put('/api/items/:id', async (request, reply) => { }); app.delete('/api/items/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const deleted = await deleteItem(Number(id)); + const deleted = await deleteItem(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); @@ -210,11 +261,18 @@ app.get('/api/recipes/:id', async (request, reply) => { return recipe; }); -app.post('/api/recipes', async (request, reply) => reply.code(201).send(await createRecipe(request.body as Record))); +app.post('/api/recipes', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createRecipe(request.body as Record, user.id)) : undefined; +}); app.put('/api/recipes/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const recipe = await updateRecipe(Number(id), request.body as Record); + const recipe = await updateRecipe(Number(id), request.body as Record, user.id); if (!recipe) { return reply.code(404).send({ message: 'Not found' }); @@ -224,12 +282,20 @@ app.put('/api/recipes/:id', async (request, reply) => { }); app.delete('/api/recipes/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { id } = request.params as { id: string }; - const deleted = await deleteRecipe(Number(id)); + const deleted = await deleteRecipe(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.get('/api/admin/config/:type', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { type } = request.params as { type: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); @@ -238,28 +304,40 @@ app.get('/api/admin/config/:type', async (request, reply) => { }); app.post('/api/admin/config/:type', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { type } = request.params as { type: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } - return reply.code(201).send(await createConfig(type, request.body as Record)); + return reply.code(201).send(await createConfig(type, request.body as Record, user.id)); }); app.put('/api/admin/config/:type/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { type, id } = request.params as { type: string; id: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } - const config = await updateConfig(type, Number(id), request.body as Record); + const config = await updateConfig(type, Number(id), request.body as Record, user.id); return config ? config : reply.code(404).send({ message: 'Not found' }); }); app.delete('/api/admin/config/:type/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } const { type, id } = request.params as { type: string; id: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } - const deleted = await deleteConfig(type, Number(id)); + const deleted = await deleteConfig(type, Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); diff --git a/frontend/src/components/EditMeta.vue b/frontend/src/components/EditMeta.vue new file mode 100644 index 0000000..9729ffd --- /dev/null +++ b/frontend/src/components/EditMeta.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7452591..cb765ec 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,7 @@ import AdminView from '../views/AdminView.vue'; import LoginView from '../views/LoginView.vue'; import RegisterView from '../views/RegisterView.vue'; import VerifyEmailView from '../views/VerifyEmailView.vue'; +import { api, getAuthToken, setAuthToken } from '../services/api'; export const router = createRouter({ history: createWebHistory(), @@ -29,3 +30,21 @@ export const router = createRouter({ ], scrollBehavior: () => ({ top: 0 }) }); + +router.beforeEach(async (to) => { + if (to.path !== '/admin') { + return true; + } + + if (!getAuthToken()) { + return { path: '/login', query: { redirect: to.fullPath } }; + } + + try { + await api.me(); + return true; + } catch { + setAuthToken(null); + return { path: '/login', query: { redirect: to.fullPath } }; + } +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1545bbe..2b19e20 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -11,7 +11,19 @@ export interface Skill extends NamedEntity { subcategory: string | null; } -export interface Pokemon { +export interface UserSummary { + id: number; + displayName: string; +} + +export interface EditInfo { + createdAt: string; + updatedAt: string; + createdBy: UserSummary | null; + updatedBy: UserSummary | null; +} + +export interface Pokemon extends EditInfo { id: number; name: string; environment: NamedEntity; @@ -30,7 +42,7 @@ export interface PokemonDetail extends Pokemon { }>; } -export interface Habitat { +export interface Habitat extends EditInfo { id: number; name: string; recipe: Array; @@ -46,7 +58,7 @@ export interface HabitatDetail extends Habitat { }>; } -export interface Item { +export interface Item extends EditInfo { id: number; name: string; category: NamedEntity; @@ -65,7 +77,7 @@ export interface ItemDetail extends Item { relatedHabitats: Array; } -export interface Recipe { +export interface Recipe extends EditInfo { id: number; name: string; materials: Array; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 5283f33..d9732f0 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -392,6 +392,13 @@ select { color: #657067; } +.edit-meta { + margin: 0; + color: #7b766a; + font-size: 13px; + font-weight: 700; +} + .chips { display: flex; flex-wrap: wrap; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 4e75003..d23b2a8 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'; import TagsSelect from '../components/TagsSelect.vue'; import { api, + type AuthUser, type ConfigType, type Habitat, type HabitatDetail, @@ -59,6 +60,7 @@ const pokemonRows = ref([]); const itemRows = ref([]); const recipeRows = ref([]); const habitatRows = ref([]); +const currentUser = ref(null); const busy = ref(false); const message = ref(''); const creatingSelect = ref(''); @@ -94,6 +96,7 @@ const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: ite const pokemonSelectOptions = computed(() => pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` })) ); +const canEdit = computed(() => currentUser.value?.emailVerified === true); function toIds(values: string[]): number[] { return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); @@ -179,10 +182,27 @@ async function loadCurrentTab() { } function setTab(tab: AdminTab) { + if (!canEdit.value) { + message.value = '请先完成邮箱验证'; + return; + } + activeTab.value = tab; void run(loadCurrentTab); } +async function loadAdmin() { + const response = await api.me(); + currentUser.value = response.user; + + if (!response.user.emailVerified) { + message.value = '请先完成邮箱验证'; + return; + } + + await loadCurrentTab(); +} + function resetConfigForm() { configForm.value = { id: 0, name: '', subcategory: '' }; } @@ -442,7 +462,7 @@ async function removeHabitat(id: number) { } onMounted(() => { - void run(loadCurrentTab); + void run(loadAdmin); }); @@ -455,7 +475,7 @@ onMounted(() => { -
+
@@ -463,7 +483,7 @@ onMounted(() => {

{{ message }}

-
+

系统配置

@@ -500,7 +520,7 @@ onMounted(() => {
-
+

Pokemon

@@ -562,7 +582,7 @@ onMounted(() => {
-
+

物品

@@ -637,7 +657,7 @@ onMounted(() => {
-
+

材料单

@@ -699,7 +719,7 @@ onMounted(() => {
-
+

栖息地

diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index 1c47061..93bc416 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -1,6 +1,7 @@