feat(pokemon): store official data identity separate from display ID

Add data_id and data_identifier to pokemon schema
Use official data ID as internal route ID for non-event pokemon
Prevent applying fetched data with mismatched ID to existing pokemon
This commit is contained in:
2026-05-04 00:06:22 +08:00
parent 8dfd03f3d2
commit fa06d24826
6 changed files with 103 additions and 44 deletions

View File

@@ -407,7 +407,8 @@
Pokemon 可配置: Pokemon 可配置:
- 内部 ID`id`,系统唯一,用于路由、外键和实体关联;普通 Pokemon 新建时优先与展示 ID 一致,活动 Pokemon 由系统分配唯一内部 ID - 内部 ID`id`,系统唯一,用于路由、外键和实体关联;从 CSV Fetch 创建的普通 Pokemon 使用官方 data Pokemon ID 作为内部 ID活动 Pokemon 和未关联官方 data 的自定义 Pokemon 由系统分配唯一内部 ID
- 官方 data 身份:`data_id``data_identifier`,可为空;用于记录该 Pokemon 对应的 CSV 官方 Pokemon ID 与 identifier不作为用户可编辑展示 ID
- 展示 ID`display_id`,详情页、列表卡片和选择器中显示为 `#ID` - 展示 ID`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
- 是否为活动物品:`is_event_item` - 是否为活动物品:`is_event_item`
- 名称 - 名称
@@ -432,7 +433,7 @@ Pokemon 可配置:
- 翻译 - 翻译
- 排序 - 排序
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。 Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。
Pokemon 编辑表单使用标签页组织字段: Pokemon 编辑表单使用标签页组织字段:
@@ -442,8 +443,9 @@ Pokemon 编辑表单使用标签页组织字段:
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称结果只展示 `#ID`、名称和 identifier。 - Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称结果只展示 `#ID`、名称和 identifier。
- Fetch 搜索结果默认关闭只在用户主动点击输入框或输入内容时展开Escape、失焦 / 点击外部、选择结果后关闭。 - Fetch 搜索结果默认关闭只在用户主动点击输入框或输入内容时展开Escape、失焦 / 点击外部、选择结果后关闭。
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。 - Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
- Fetch 只填入 CSV 可提供的字段:官方 data ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。 - Fetch 只填入 CSV 可提供的字段:官方 data ID、官方 data identifier、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
- Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。 - Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
- Fetch 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID展示 ID 只保存到 `display_id`
- Fetch 不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。 - Fetch 不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en``ja``ko``fr``de``es``it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans``zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant` - Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en``ja``ko``fr``de``es``it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans``zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`Type ID 与 `data/localized_type_name.csv``frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。 - Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`Type ID 与 `data/localized_type_name.csv``frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。

View File

@@ -712,6 +712,8 @@ CREATE TABLE IF NOT EXISTS pokemon_types (
CREATE TABLE IF NOT EXISTS pokemon ( CREATE TABLE IF NOT EXISTS pokemon (
id integer PRIMARY KEY, id integer PRIMARY KEY,
data_id integer CHECK (data_id > 0),
data_identifier text NOT NULL DEFAULT '',
display_id integer NOT NULL CHECK (display_id > 0), display_id integer NOT NULL CHECK (display_id > 0),
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
is_event_item boolean NOT NULL DEFAULT false, is_event_item boolean NOT NULL DEFAULT false,
@@ -738,6 +740,10 @@ CREATE TABLE IF NOT EXISTS pokemon (
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE pokemon
ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0),
ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types ( CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE, type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,

View File

@@ -110,6 +110,8 @@ type PokemonImageOptionsResult = {
}; };
type PokemonPayload = { type PokemonPayload = {
dataId: number | null;
dataIdentifier: string;
displayId: number; displayId: number;
isEventItem: boolean; isEventItem: boolean;
name: string; name: string;
@@ -897,28 +899,19 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
return result.rows[0]?.sortOrder ?? 10; return result.rows[0]?.sortOrder ?? 10;
} }
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> { async function nextPokemonInternalId(
if (isEventItem) { client: DbClient,
dataId: number | null,
isEventItem: boolean
): Promise<number> {
if (!isEventItem && dataId !== null) {
return dataId;
}
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000' 'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
); );
const nextId = result.rows[0]?.id ?? 1000000; return result.rows[0]?.id ?? 1000000;
return nextId === displayId ? nextId + 1 : nextId;
}
if (!isEventItem) {
const preferredId = await client.query<{ id: number }>('SELECT id FROM pokemon WHERE id = $1', [displayId]);
if (preferredId.rowCount === 0) {
return displayId;
}
}
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1',
[displayId]
);
const nextId = result.rows[0]?.id ?? 1;
return nextId === displayId ? nextId + 1 : nextId;
} }
async function reorderTableRows( async function reorderTableRows(
@@ -2160,6 +2153,8 @@ function pokemonProjection(locale: string): string {
return ` return `
SELECT SELECT
p.id, p.id,
p.data_id AS "dataId",
p.data_identifier AS "dataIdentifier",
p.display_id AS "displayId", p.display_id AS "displayId",
${pokemonName} AS name, ${pokemonName} AS name,
p.name AS "baseName", p.name AS "baseName",
@@ -4684,6 +4679,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired'); const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
return { return {
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
displayId, displayId,
isEventItem: Boolean(payload.isEventItem), isEventItem: Boolean(payload.isEventItem),
name: cleanName(payload.name, 'server.validation.pokemonNameRequired'), name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
@@ -4702,6 +4699,21 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
}; };
} }
async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise<void> {
if (payload.dataId === null) {
payload.dataIdentifier = '';
return;
}
const data = await loadPokemonCsvData();
const pokemonRow = data.pokemonByLookup.get(String(payload.dataId));
if (!pokemonRow) {
throw validationError('server.validation.pokemonDataNotFound');
}
payload.dataIdentifier = csvText(pokemonRow, 'identifier');
}
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> { async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
@@ -4749,14 +4761,17 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) { export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload(payload); const cleanPayload = cleanPokemonPayload(payload);
await normalizePokemonDataIdentity(cleanPayload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const pokemonId = await nextPokemonInternalId(client, cleanPayload.displayId, cleanPayload.isEventItem); const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId, cleanPayload.isEventItem);
const sortOrder = await nextSortOrder(client, 'pokemon'); const sortOrder = await nextSortOrder(client, 'pokemon');
await client.query( await client.query(
` `
INSERT INTO pokemon ( INSERT INTO pokemon (
id, id,
data_id,
data_identifier,
display_id, display_id,
name, name,
is_event_item, is_event_item,
@@ -4780,10 +4795,12 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
created_by_user_id, created_by_user_id,
updated_by_user_id updated_by_user_id
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $22) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $24)
`, `,
[ [
pokemonId, pokemonId,
cleanPayload.dataId,
cleanPayload.dataIdentifier,
cleanPayload.displayId, cleanPayload.displayId,
cleanPayload.name, cleanPayload.name,
cleanPayload.isEventItem, cleanPayload.isEventItem,
@@ -4818,6 +4835,10 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) { export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload(payload); const cleanPayload = cleanPokemonPayload(payload);
await normalizePokemonDataIdentity(cleanPayload);
if (!cleanPayload.isEventItem && cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
throw validationError('server.validation.pokemonDataIdMismatch');
}
const before = await getPokemon(id, defaultLocale); const before = await getPokemon(id, defaultLocale);
const updated = await withTransaction(async (client) => { const updated = await withTransaction(async (client) => {
@@ -4825,30 +4846,34 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
` `
UPDATE pokemon UPDATE pokemon
SET SET
display_id = $1, data_id = $1,
name = $2, data_identifier = $2,
is_event_item = $3, display_id = $3,
genus = $4, name = $4,
details = $5, is_event_item = $5,
height_inches = $6, genus = $6,
weight_pounds = $7, details = $7,
environment_id = $8, height_inches = $8,
hp = $9, weight_pounds = $9,
attack = $10, environment_id = $10,
defense = $11, hp = $11,
special_attack = $12, attack = $12,
special_defense = $13, defense = $13,
speed = $14, special_attack = $14,
image_path = $15, special_defense = $15,
image_style = $16, speed = $16,
image_version = $17, image_path = $17,
image_variant = $18, image_style = $18,
image_description = $19, image_version = $19,
updated_by_user_id = $20, image_variant = $20,
image_description = $21,
updated_by_user_id = $22,
updated_at = now() updated_at = now()
WHERE id = $21 WHERE id = $23
`, `,
[ [
cleanPayload.dataId,
cleanPayload.dataIdentifier,
cleanPayload.displayId, cleanPayload.displayId,
cleanPayload.name, cleanPayload.name,
cleanPayload.isEventItem, cleanPayload.isEventItem,

View File

@@ -151,6 +151,8 @@ export interface EditHistoryEntry {
export interface Pokemon extends EditInfo { export interface Pokemon extends EditInfo {
id: number; id: number;
dataId?: number | null;
dataIdentifier?: string;
displayId: number; displayId: number;
name: string; name: string;
baseName?: string; baseName?: string;
@@ -534,6 +536,8 @@ export type ConfigType =
| 'game-versions'; | 'game-versions';
export interface PokemonPayload { export interface PokemonPayload {
dataId?: number | null;
dataIdentifier?: string;
displayId: number; displayId: number;
isEventItem: boolean; isEventItem: boolean;
name: string; name: string;

View File

@@ -79,6 +79,8 @@ function defaultPokemonStats(): PokemonStats {
} }
const pokemonForm = ref({ const pokemonForm = ref({
dataId: null as number | null,
dataIdentifier: '',
id: '', id: '',
isEventItem: false, isEventItem: false,
name: '', name: '',
@@ -257,8 +259,22 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
} }
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean { function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
const routePokemonId = Number(routeId.value);
if (
isEditing.value &&
!pokemonForm.value.isEventItem &&
Number.isInteger(routePokemonId) &&
routePokemonId > 0 &&
fetchedPokemon.id !== routePokemonId
) {
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
return false;
}
pokemonForm.value = { pokemonForm.value = {
...pokemonForm.value, ...pokemonForm.value,
dataId: fetchedPokemon.id,
dataIdentifier: fetchedPokemon.identifier,
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id, id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
name: fetchedPokemon.name, name: fetchedPokemon.name,
genus: fetchedPokemon.genus, genus: fetchedPokemon.genus,
@@ -295,6 +311,8 @@ async function loadEditor() {
if (isEditing.value) { if (isEditing.value) {
const pokemon = await api.pokemonDetail(routeId.value); const pokemon = await api.pokemonDetail(routeId.value);
pokemonForm.value = { pokemonForm.value = {
dataId: pokemon.dataId ?? null,
dataIdentifier: pokemon.dataIdentifier ?? '',
id: String(pokemon.displayId), id: String(pokemon.displayId),
isEventItem: pokemon.isEventItem, isEventItem: pokemon.isEventItem,
name: pokemon.baseName ?? pokemon.name, name: pokemon.baseName ?? pokemon.name,
@@ -678,6 +696,8 @@ async function savePokemon() {
try { try {
const payload: PokemonPayload = { const payload: PokemonPayload = {
dataId: pokemonForm.value.dataId,
dataIdentifier: pokemonForm.value.dataIdentifier,
displayId: pokemonIdForSave(), displayId: pokemonIdForSave(),
isEventItem: pokemonForm.value.isEventItem, isEventItem: pokemonForm.value.isEventItem,
name: pokemonNameForSave(), name: pokemonNameForSave(),

View File

@@ -1054,6 +1054,7 @@ export const systemWordingMessages = {
pokemonIdentifierRequired: 'Pokemon identifier is required', pokemonIdentifierRequired: 'Pokemon identifier is required',
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable', pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
pokemonDataNotFound: 'Pokemon data was not found', pokemonDataNotFound: 'Pokemon data was not found',
pokemonDataIdMismatch: 'Pokemon data ID does not match this Pokemon',
pokemonImagePathInvalid: 'Pokemon image path is invalid', pokemonImagePathInvalid: 'Pokemon image path is invalid',
imagePathInvalid: 'Image path is invalid', imagePathInvalid: 'Image path is invalid',
imageUploadRequired: 'Please select an image', imageUploadRequired: 'Please select an image',
@@ -2167,6 +2168,7 @@ export const systemWordingMessages = {
pokemonIdentifierRequired: '请输入 Pokemon 标识', pokemonIdentifierRequired: '请输入 Pokemon 标识',
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用', pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
pokemonDataNotFound: '未找到 Pokemon 数据', pokemonDataNotFound: '未找到 Pokemon 数据',
pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致',
pokemonImagePathInvalid: 'Pokemon 图片路径不合法', pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
imagePathInvalid: '图片路径不合法', imagePathInvalid: '图片路径不合法',
imageUploadRequired: '请选择图片', imageUploadRequired: '请选择图片',