From 9d25f13fe967ddb38be4740d403eb644362c52f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 16:05:10 +0800 Subject: [PATCH] =?UTF-8?q?task-71(ASIN=20=E6=95=B0=E6=8D=AE=E4=B8=AD?= =?UTF-8?q?=E5=BF=83):=20=E5=AE=9E=E7=8E=B0=E6=9F=A5=E8=AF=A2=20ASIN=20?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=92=8C=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/admin/query-asins 列表解析(QueryAsinPageVo, 每店铺 5 国家 ASIN 宽行) + 分组/店铺名/ASIN/国家筛选归一与 camel 序列化(页大小上限 100 对齐后端), 9 个纯逻辑契约测试覆盖正常/边界/异常路径。 --- .../src/pages/asin/query-asin-api.ts | 16 ++ .../src/pages/asin/query-asin-filter.ts | 103 ++++++++++ .../src/pages/asin/query-asin-model.ts | 100 ++++++++++ admin-frontend-vue/tests/task-71.test.ts | 176 ++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 admin-frontend-vue/src/pages/asin/query-asin-api.ts create mode 100644 admin-frontend-vue/src/pages/asin/query-asin-filter.ts create mode 100644 admin-frontend-vue/src/pages/asin/query-asin-model.ts create mode 100644 admin-frontend-vue/tests/task-71.test.ts diff --git a/admin-frontend-vue/src/pages/asin/query-asin-api.ts b/admin-frontend-vue/src/pages/asin/query-asin-api.ts new file mode 100644 index 00000000..e2d9e35c --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/query-asin-api.ts @@ -0,0 +1,16 @@ +/** 查询 ASIN 列表查询适配(任务 71):GET /api/admin/query-asins + 分页/筛选归一与 camel 序列化。 */ +import { http } from '@/api/http' +import { parseQueryAsinPage, type QueryAsinPageResult } from './query-asin-model' +import { + normalizeQueryAsinParams, + toQueryAsinQuery, + type QueryAsinListParams, +} from './query-asin-filter' + +export const QUERY_ASIN_ENDPOINT = '/api/admin/query-asins' + +export async function fetchQueryAsinList(params: Partial = {}): Promise { + const normalized = normalizeQueryAsinParams(params) + const { data } = await http.get(QUERY_ASIN_ENDPOINT, { params: toQueryAsinQuery(normalized) }) + return parseQueryAsinPage(data) +} diff --git a/admin-frontend-vue/src/pages/asin/query-asin-filter.ts b/admin-frontend-vue/src/pages/asin/query-asin-filter.ts new file mode 100644 index 00000000..f2b3d2fb --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/query-asin-filter.ts @@ -0,0 +1,103 @@ +/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;Java 参数名 camelCase。 */ +import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts' +import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts' + +/** 后端 QueryAsinService 对 pageSize 的上限;超发会被服务端静默截断,前端先行对齐。 */ +export const QUERY_ASIN_MAX_PAGE_SIZE = 100 + +export interface QueryAsinFilterState { + groupId: number | null + shopName: string + asin: string + /** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */ + country: string +} + +export interface QueryAsinListParams { + page: number + pageSize: number + groupId?: number | null + shopName?: string + asin?: string + country?: string +} + +export interface QueryAsinQuery { + page: number + pageSize: number + groupId?: number + shopName?: string + asin?: string + country?: string +} + +export function createQueryAsinFilterState(): QueryAsinFilterState { + return { groupId: null, shopName: '', asin: '', country: '' } +} + +/** 国家码归一:大小写不敏感;不属 DE/UK/FR/IT/ES 白名单时返回空串(不发起后端不支持的筛选)。 */ +export function normalizeQueryAsinCountry(value: string): string { + const upper = (value || '').trim().toUpperCase() + return QUERY_ASIN_COUNTRIES.includes(upper as (typeof QUERY_ASIN_COUNTRIES)[number]) ? upper : '' +} + +/** 是否有任一激活筛选条件。 */ +export function queryAsinFilterActive(state: QueryAsinFilterState): boolean { + if (typeof state.groupId === 'number' && state.groupId >= 1) return true + if ((state.shopName || '').trim()) return true + if ((state.asin || '').trim()) return true + return normalizeQueryAsinCountry(state.country) !== '' +} + +/** 归一化列表查询参数:页码下限 1、页大小 1..100、空白与非法条件不下发。 */ +export function normalizeQueryAsinParams(raw: Partial): QueryAsinListParams { + const page = typeof raw.page === 'number' && Number.isFinite(raw.page) + ? Math.max(Math.floor(raw.page), 1) + : 1 + const rawSize = typeof raw.pageSize === 'number' && Number.isFinite(raw.pageSize) + ? Math.floor(raw.pageSize) + : NaN + const pageSize = + Number.isNaN(rawSize) || rawSize < 1 + ? ASIN_PAGE_DEFAULT_SIZE + : Math.min(rawSize, QUERY_ASIN_MAX_PAGE_SIZE) + const groupId = + typeof raw.groupId === 'number' && Number.isFinite(raw.groupId) && raw.groupId >= 1 + ? Math.floor(raw.groupId) + : null + const shopName = typeof raw.shopName === 'string' ? raw.shopName.trim() : '' + const asin = typeof raw.asin === 'string' ? raw.asin.trim() : '' + const country = normalizeQueryAsinCountry(typeof raw.country === 'string' ? raw.country : '') + const params: QueryAsinListParams = { page, pageSize } + if (groupId !== null) params.groupId = groupId + if (shopName) params.shopName = shopName + if (asin) params.asin = asin + if (country) params.country = country + return params +} + +/** 筛选态 + 页码 → 列表查询参数(已归一,可幂等重入)。 */ +export function toQueryAsinParams( + state: QueryAsinFilterState, + page: number, + pageSize: number, +): QueryAsinListParams { + return normalizeQueryAsinParams({ + page, + pageSize, + groupId: state.groupId, + shopName: state.shopName, + asin: state.asin, + country: state.country, + }) +} + +/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(camel) 对齐,仅下发非空字段。 */ +export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery { + const query: QueryAsinQuery = { page: params.page, pageSize: params.pageSize } + if (typeof params.groupId === 'number' && params.groupId >= 1) query.groupId = params.groupId + if (params.shopName) query.shopName = params.shopName + if (params.asin) query.asin = params.asin + if (params.country) query.country = params.country + return query +} diff --git a/admin-frontend-vue/src/pages/asin/query-asin-model.ts b/admin-frontend-vue/src/pages/asin/query-asin-model.ts new file mode 100644 index 00000000..7d16e082 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/query-asin-model.ts @@ -0,0 +1,100 @@ +/** 查询 ASIN 列表查询模型(任务 71):解析 QueryAsinPageVo(camel) 与每店铺 5 国家 ASIN 宽行,纯逻辑。 */ +import { unwrap } from '../../api/envelope.ts' +import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts' + +export const QUERY_ASIN_COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const +export type QueryAsinCountry = (typeof QUERY_ASIN_COUNTRIES)[number] + +export interface QueryAsinItem { + id: number + groupId: number | null + groupName: string + shopName: string + asinDe: string + asinUk: string + asinFr: string + asinIt: string + asinEs: string + createdAt?: string + updatedAt?: string +} + +export interface QueryAsinPageResult { + items: QueryAsinItem[] + total: number + page: number + pageSize: number +} + +export function emptyQueryAsinPage(): QueryAsinPageResult { + return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE } +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null +} + +const COUNTRY_COLUMN: Record> = { + DE: 'asinDe', + UK: 'asinUk', + FR: 'asinFr', + IT: 'asinIt', + ES: 'asinEs', +} + +/** 解析单条查询 ASIN 店铺行;缺 id 视为无效。 */ +export function toQueryAsinItem(raw: unknown): QueryAsinItem | null { + if (!raw || typeof raw !== 'object') return null + const record = raw as Record + const id = numberOrNull(record.id) + if (id === null) return null + const item: QueryAsinItem = { + id, + groupId: numberOrNull(record.groupId ?? record.group_id), + groupName: text(record.groupName ?? record.group_name), + shopName: text(record.shopName ?? record.shop_name), + asinDe: text(record.asinDe ?? record.asin_de), + asinUk: text(record.asinUk ?? record.asin_uk), + asinFr: text(record.asinFr ?? record.asin_fr), + asinIt: text(record.asinIt ?? record.asin_it), + asinEs: text(record.asinEs ?? record.asin_es), + } + const createdAt = text(record.createdAt ?? record.created_at) + if (createdAt) item.createdAt = createdAt + const updatedAt = text(record.updatedAt ?? record.updated_at) + if (updatedAt) item.updatedAt = updatedAt + return item +} + +/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */ +export function parseQueryAsinPage(payload: unknown): QueryAsinPageResult { + const out = emptyQueryAsinPage() + const core = unwrap(payload) + if (!core || typeof core !== 'object') return out + const record = core as Record + if (Array.isArray(record.items)) { + out.items = record.items + .map((raw) => toQueryAsinItem(raw)) + .filter((item): item is QueryAsinItem => item !== null) + } + if (typeof record.total === 'number') out.total = Math.floor(record.total) + if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page) + const rawSize = record.pageSize ?? record.page_size + if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize) + return out +} + +/** 取某国家列 ASIN;国家码不属白名单时返回空串。 */ +export function asinForCountry(item: QueryAsinItem, country: string): string { + const column = COUNTRY_COLUMN[country] + return column ? item[column] : '' +} + +/** 该店铺行是否存在任一国家 ASIN。 */ +export function queryAsinItemHasAnyAsin(item: QueryAsinItem): boolean { + return QUERY_ASIN_COUNTRIES.some((code) => asinForCountry(item, code) !== '') +} diff --git a/admin-frontend-vue/tests/task-71.test.ts b/admin-frontend-vue/tests/task-71.test.ts new file mode 100644 index 00000000..28eb47f1 --- /dev/null +++ b/admin-frontend-vue/tests/task-71.test.ts @@ -0,0 +1,176 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + parseQueryAsinPage, + toQueryAsinItem, + asinForCountry, + queryAsinItemHasAnyAsin, + QUERY_ASIN_COUNTRIES, +} from '../src/pages/asin/query-asin-model.ts' +import { + createQueryAsinFilterState, + queryAsinFilterActive, + normalizeQueryAsinCountry, + toQueryAsinParams, + toQueryAsinQuery, +} from '../src/pages/asin/query-asin-filter.ts' + +test('test_task_071_query_asin_list_normal_primary_path', () => { + // 正常主路径:Java QueryAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN)解析为前端分页结果。 + const page = parseQueryAsinPage({ + success: true, + data: { + items: [ + { + id: 11, + groupId: 3, + groupName: '华东组', + shopName: 'BlueWave', + asinDe: 'B0DE001', + asinUk: 'B0UK001', + asinFr: 'B0FR001', + asinIt: 'B0IT001', + asinEs: 'B0ES001', + createdAt: '2026-01-01T10:00:00', + updatedAt: '2026-01-02T11:00:00', + }, + ], + total: 1, + page: 1, + pageSize: 15, + }, + }) + assert.equal(page.items.length, 1) + const item = page.items[0] + assert.equal(item.id, 11) + assert.equal(item.groupId, 3) + assert.equal(item.groupName, '华东组') + assert.equal(item.shopName, 'BlueWave') + assert.equal(item.asinDe, 'B0DE001') + assert.equal(item.asinUk, 'B0UK001') + assert.equal(item.asinFr, 'B0FR001') + assert.equal(item.asinIt, 'B0IT001') + assert.equal(item.asinEs, 'B0ES001') + assert.equal(item.createdAt, '2026-01-01T10:00:00') + assert.equal(page.total, 1) + assert.equal(page.pageSize, 15) +}) + +test('test_task_071_query_asin_list_normal_variant_input', () => { + // 正常变体:已解包 VO 直传归一;蛇形 asin_de 兜底;缺国家 ASIN/分组留空。 + const page = parseQueryAsinPage({ + items: [{ id: 12, shopName: 'RedSun', asin_de: 'B0DE002', asinUk: 'B0UK002' }], + total: 4, + page: 2, + pageSize: 20, + }) + const item = page.items[0] + assert.equal(item.shopName, 'RedSun') + assert.equal(item.asinDe, 'B0DE002') + assert.equal(item.asinUk, 'B0UK002') + assert.equal(item.asinFr, '') + assert.equal(item.groupId, null) + assert.equal(item.groupName, '') + assert.equal(page.page, 2) + assert.equal(page.pageSize, 20) +}) + +test('test_task_071_query_asin_list_normal_repeated_is_idempotent', () => { + // 正常重复:解析与筛选参数生成不改输入、结果稳定。 + const payload = { + data: { items: [{ id: 13, shopName: 'A', asinDe: 'X' }], total: 1, page: 1, pageSize: 15 }, + } + assert.deepEqual(parseQueryAsinPage(payload), parseQueryAsinPage(payload)) + const state = { groupId: null, shopName: ' 店 ', asin: '', country: '' } + const first = toQueryAsinParams(state, 1, 15) + const second = toQueryAsinParams(state, 1, 15) + assert.deepEqual(first, second) + assert.deepEqual(state, { groupId: null, shopName: ' 店 ', asin: '', country: '' }, '不修改调用方状态') +}) + +test('test_task_071_query_asin_list_boundary_empty_input', () => { + // 边界空值:空负载回默认分页结果(空列表/total 0/page 1/页大小 15)。 + const page = parseQueryAsinPage({}) + assert.deepEqual(page.items, []) + assert.equal(page.total, 0) + assert.equal(page.page, 1) + assert.equal(page.pageSize, 15) +}) + +test('test_task_071_query_asin_list_boundary_single_item', () => { + // 边界单元素:单条记录;缺 id 的行被过滤。 + const page = parseQueryAsinPage({ + data: { items: [{ id: 7, shopName: 'Z' }, { shopName: 'no-id' }], total: 2, page: 1, pageSize: 15 }, + }) + assert.equal(page.items.length, 1) + assert.equal(page.items[0].id, 7) +}) + +test('test_task_071_query_asin_list_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:页码下限 1、页大小上限 100 对齐后端;空白/非法/小于1的分组不下发。 + const query = toQueryAsinQuery(toQueryAsinParams( + { groupId: 0, shopName: ' ', asin: '', country: 'XX' }, + 0, + 999, + )) + assert.equal(query.page, 1) + assert.equal(query.pageSize, 100) + assert.equal('groupId' in query, false, '分组 0 不应下发') + assert.equal('shopName' in query, false, '空白店铺名不应下发') + assert.equal('asin' in query, false) + assert.equal('country' in query, false, '不支持的国家码不应下发') + // 正常非空值仍以 Java camelCase 参数名下发。 + const filled = toQueryAsinQuery(toQueryAsinParams( + { groupId: 5, shopName: ' 蓝店 ', asin: 'B0X', country: 'de' }, + 3, + 25, + )) + assert.equal(filled.page, 3) + assert.equal(filled.pageSize, 25) + assert.equal(filled.groupId, 5) + assert.equal(filled.shopName, '蓝店') + assert.equal(filled.asin, 'B0X') + assert.equal(filled.country, 'DE') + assert.equal('page_size' in (filled as Record), false, '不应下发 page_size(snake)') +}) + +test('test_task_071_query_asin_country_semantics', () => { + // 国家语义:每店铺行按国家取 ASIN 列、整行是否有任一国家 ASIN;国家码大小写归一与白名单。 + const item = toQueryAsinItem({ id: 21, shopName: 'S', asinDe: 'DE1', asinEs: 'ES1' }) + assert.ok(item) + assert.equal(QUERY_ASIN_COUNTRIES.length, 5) + assert.equal(asinForCountry(item!, 'DE'), 'DE1') + assert.equal(asinForCountry(item!, 'UK'), '') + assert.equal(asinForCountry(item!, 'ES'), 'ES1') + assert.equal(asinForCountry(item!, 'XX'), '') + assert.equal(queryAsinItemHasAnyAsin(item!), true) + const empty = toQueryAsinItem({ id: 22, shopName: 'E' }) + assert.equal(queryAsinItemHasAnyAsin(empty!), false) + assert.equal(normalizeQueryAsinCountry('de'), 'DE') + assert.equal(normalizeQueryAsinCountry('UK'), 'UK') + assert.equal(normalizeQueryAsinCountry(''), '') + assert.equal(normalizeQueryAsinCountry('CN'), '') + assert.equal(queryAsinFilterActive({ groupId: null, shopName: '', asin: '', country: '' }), false) + assert.equal(queryAsinFilterActive({ groupId: null, shopName: ' 店 ', asin: '', country: '' }), true) + assert.equal(queryAsinFilterActive({ groupId: 2, shopName: '', asin: '', country: '' }), true) +}) + +test('test_task_071_query_asin_list_invalid_input_rejected', () => { + // 异常输入:success=false 抛后端 message;非对象/非数字 id 行不入列表;非法国家被归一丢弃。 + assert.throws(() => parseQueryAsinPage({ success: false, message: '无权访问查询 ASIN 数据' }), /无权访问/) + assert.equal(toQueryAsinItem('garbage'), null) + assert.equal(toQueryAsinItem({ shopName: 'no id' }), null) + assert.deepEqual(createQueryAsinFilterState(), { groupId: null, shopName: '', asin: '', country: '' }) +}) + +test('test_task_071_query_asin_list_dependency_failure_returns_actionable_message', () => { + // 依赖失败/加载走 adapter:GET /api/admin/query-asins + http.get + 解析,页面不内联。 + const api = readSource('src/pages/asin/query-asin-api.ts') + assert.match(api, /\/api\/admin\/query-asins/) + assert.match(api, /http\.get/) + assert.match(api, /parseQueryAsinPage/) + assert.match(api, /toQueryAsinQuery/) + const model = readSource('src/pages/asin/query-asin-model.ts') + assert.equal(/axios|http\./.test(model), false, '查询 ASIN 列表解析保持纯逻辑') +})