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:
@@ -407,7 +407,8 @@
|
||||
|
||||
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`
|
||||
- 是否为活动物品:`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 编辑表单使用标签页组织字段:
|
||||
|
||||
@@ -442,8 +443,9 @@ Pokemon 编辑表单使用标签页组织字段:
|
||||
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
||||
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
||||
- 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 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID;展示 ID 只保存到 `display_id`。
|
||||
- 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 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||
|
||||
@@ -712,6 +712,8 @@ CREATE TABLE IF NOT EXISTS pokemon_types (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon (
|
||||
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),
|
||||
name text NOT NULL UNIQUE,
|
||||
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()
|
||||
);
|
||||
|
||||
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 (
|
||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -110,6 +110,8 @@ type PokemonImageOptionsResult = {
|
||||
};
|
||||
|
||||
type PokemonPayload = {
|
||||
dataId: number | null;
|
||||
dataIdentifier: string;
|
||||
displayId: number;
|
||||
isEventItem: boolean;
|
||||
name: string;
|
||||
@@ -897,28 +899,19 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
|
||||
return result.rows[0]?.sortOrder ?? 10;
|
||||
}
|
||||
|
||||
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> {
|
||||
if (isEventItem) {
|
||||
async function nextPokemonInternalId(
|
||||
client: DbClient,
|
||||
dataId: number | null,
|
||||
isEventItem: boolean
|
||||
): Promise<number> {
|
||||
if (!isEventItem && dataId !== null) {
|
||||
return dataId;
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
|
||||
);
|
||||
const nextId = 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;
|
||||
return result.rows[0]?.id ?? 1000000;
|
||||
}
|
||||
|
||||
async function reorderTableRows(
|
||||
@@ -2160,6 +2153,8 @@ function pokemonProjection(locale: string): string {
|
||||
return `
|
||||
SELECT
|
||||
p.id,
|
||||
p.data_id AS "dataId",
|
||||
p.data_identifier AS "dataIdentifier",
|
||||
p.display_id AS "displayId",
|
||||
${pokemonName} AS name,
|
||||
p.name AS "baseName",
|
||||
@@ -4684,6 +4679,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
|
||||
|
||||
return {
|
||||
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
|
||||
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
|
||||
displayId,
|
||||
isEventItem: Boolean(payload.isEventItem),
|
||||
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> {
|
||||
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]);
|
||||
@@ -4749,14 +4761,17 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
|
||||
|
||||
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
const cleanPayload = cleanPokemonPayload(payload);
|
||||
await normalizePokemonDataIdentity(cleanPayload);
|
||||
|
||||
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');
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO pokemon (
|
||||
id,
|
||||
data_id,
|
||||
data_identifier,
|
||||
display_id,
|
||||
name,
|
||||
is_event_item,
|
||||
@@ -4780,10 +4795,12 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
created_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,
|
||||
cleanPayload.dataId,
|
||||
cleanPayload.dataIdentifier,
|
||||
cleanPayload.displayId,
|
||||
cleanPayload.name,
|
||||
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) {
|
||||
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 updated = await withTransaction(async (client) => {
|
||||
@@ -4825,30 +4846,34 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
||||
`
|
||||
UPDATE pokemon
|
||||
SET
|
||||
display_id = $1,
|
||||
name = $2,
|
||||
is_event_item = $3,
|
||||
genus = $4,
|
||||
details = $5,
|
||||
height_inches = $6,
|
||||
weight_pounds = $7,
|
||||
environment_id = $8,
|
||||
hp = $9,
|
||||
attack = $10,
|
||||
defense = $11,
|
||||
special_attack = $12,
|
||||
special_defense = $13,
|
||||
speed = $14,
|
||||
image_path = $15,
|
||||
image_style = $16,
|
||||
image_version = $17,
|
||||
image_variant = $18,
|
||||
image_description = $19,
|
||||
updated_by_user_id = $20,
|
||||
data_id = $1,
|
||||
data_identifier = $2,
|
||||
display_id = $3,
|
||||
name = $4,
|
||||
is_event_item = $5,
|
||||
genus = $6,
|
||||
details = $7,
|
||||
height_inches = $8,
|
||||
weight_pounds = $9,
|
||||
environment_id = $10,
|
||||
hp = $11,
|
||||
attack = $12,
|
||||
defense = $13,
|
||||
special_attack = $14,
|
||||
special_defense = $15,
|
||||
speed = $16,
|
||||
image_path = $17,
|
||||
image_style = $18,
|
||||
image_version = $19,
|
||||
image_variant = $20,
|
||||
image_description = $21,
|
||||
updated_by_user_id = $22,
|
||||
updated_at = now()
|
||||
WHERE id = $21
|
||||
WHERE id = $23
|
||||
`,
|
||||
[
|
||||
cleanPayload.dataId,
|
||||
cleanPayload.dataIdentifier,
|
||||
cleanPayload.displayId,
|
||||
cleanPayload.name,
|
||||
cleanPayload.isEventItem,
|
||||
|
||||
@@ -151,6 +151,8 @@ export interface EditHistoryEntry {
|
||||
|
||||
export interface Pokemon extends EditInfo {
|
||||
id: number;
|
||||
dataId?: number | null;
|
||||
dataIdentifier?: string;
|
||||
displayId: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
@@ -534,6 +536,8 @@ export type ConfigType =
|
||||
| 'game-versions';
|
||||
|
||||
export interface PokemonPayload {
|
||||
dataId?: number | null;
|
||||
dataIdentifier?: string;
|
||||
displayId: number;
|
||||
isEventItem: boolean;
|
||||
name: string;
|
||||
|
||||
@@ -79,6 +79,8 @@ function defaultPokemonStats(): PokemonStats {
|
||||
}
|
||||
|
||||
const pokemonForm = ref({
|
||||
dataId: null as number | null,
|
||||
dataIdentifier: '',
|
||||
id: '',
|
||||
isEventItem: false,
|
||||
name: '',
|
||||
@@ -257,8 +259,22 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
|
||||
}
|
||||
|
||||
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,
|
||||
dataId: fetchedPokemon.id,
|
||||
dataIdentifier: fetchedPokemon.identifier,
|
||||
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
|
||||
name: fetchedPokemon.name,
|
||||
genus: fetchedPokemon.genus,
|
||||
@@ -295,6 +311,8 @@ async function loadEditor() {
|
||||
if (isEditing.value) {
|
||||
const pokemon = await api.pokemonDetail(routeId.value);
|
||||
pokemonForm.value = {
|
||||
dataId: pokemon.dataId ?? null,
|
||||
dataIdentifier: pokemon.dataIdentifier ?? '',
|
||||
id: String(pokemon.displayId),
|
||||
isEventItem: pokemon.isEventItem,
|
||||
name: pokemon.baseName ?? pokemon.name,
|
||||
@@ -678,6 +696,8 @@ async function savePokemon() {
|
||||
|
||||
try {
|
||||
const payload: PokemonPayload = {
|
||||
dataId: pokemonForm.value.dataId,
|
||||
dataIdentifier: pokemonForm.value.dataIdentifier,
|
||||
displayId: pokemonIdForSave(),
|
||||
isEventItem: pokemonForm.value.isEventItem,
|
||||
name: pokemonNameForSave(),
|
||||
|
||||
@@ -1054,6 +1054,7 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: 'Pokemon identifier is required',
|
||||
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
||||
pokemonDataNotFound: 'Pokemon data was not found',
|
||||
pokemonDataIdMismatch: 'Pokemon data ID does not match this Pokemon',
|
||||
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
||||
imagePathInvalid: 'Image path is invalid',
|
||||
imageUploadRequired: 'Please select an image',
|
||||
@@ -2167,6 +2168,7 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: '请输入 Pokemon 标识',
|
||||
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
||||
pokemonDataNotFound: '未找到 Pokemon 数据',
|
||||
pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致',
|
||||
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
||||
imagePathInvalid: '图片路径不合法',
|
||||
imageUploadRequired: '请选择图片',
|
||||
|
||||
Reference in New Issue
Block a user