From 8c6088bdc99b4ad5d8b657eed268d966170fe7bd 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:34:26 +0800 Subject: [PATCH] =?UTF-8?q?task-82(=E5=BA=97=E9=93=BA=E4=B8=AD=E5=BF=83):?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E5=BA=97=E9=93=BA=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 shop-key-model.ts(ShopKeyPageVo camel 解析、白名单状态归一、snake 兜底) 与 shop-key-api.ts(GET /api/admin/shop-keys 加载适配);纯逻辑解析、 页面不内联请求。 TDD: task-82.test.ts 8 用例先 RED 后 GREEN。 --- .../src/pages/shop/shop-key-api.ts | 12 ++ .../src/pages/shop/shop-key-model.ts | 66 ++++++++++ admin-frontend-vue/tests/task-82.test.ts | 116 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 admin-frontend-vue/src/pages/shop/shop-key-api.ts create mode 100644 admin-frontend-vue/src/pages/shop/shop-key-model.ts create mode 100644 admin-frontend-vue/tests/task-82.test.ts diff --git a/admin-frontend-vue/src/pages/shop/shop-key-api.ts b/admin-frontend-vue/src/pages/shop/shop-key-api.ts new file mode 100644 index 00000000..975df67d --- /dev/null +++ b/admin-frontend-vue/src/pages/shop/shop-key-api.ts @@ -0,0 +1,12 @@ +/** 店铺密钥列表加载适配(任务 82):GET /api/admin/shop-keys + 分页归一与解析。 */ +import { http } from '@/api/http' +import { parseShopKeyPage } from './shop-key-model' +import { normalizeKeyListParams, toKeyPageQuery, type ShopKeyListParams, type ShopKeyPageResult } from './shop-dto.ts' + +export const SHOP_KEYS_ENDPOINT = '/api/admin/shop-keys' + +export async function fetchShopKeyList(params: Partial = {}): Promise { + const normalized = normalizeKeyListParams(params) + const { data } = await http.get(SHOP_KEYS_ENDPOINT, { params: toKeyPageQuery(normalized) }) + return parseShopKeyPage(data) +} diff --git a/admin-frontend-vue/src/pages/shop/shop-key-model.ts b/admin-frontend-vue/src/pages/shop/shop-key-model.ts new file mode 100644 index 00000000..bba00d8c --- /dev/null +++ b/admin-frontend-vue/src/pages/shop/shop-key-model.ts @@ -0,0 +1,66 @@ +/** 店铺密钥列表加载模型(任务 82):解析 ShopKeyPageVo(camel) 密钥行,纯逻辑。 */ +import { unwrap } from '../../api/envelope.ts' +import { + emptyShopKeyPageResult, + toSensitiveString, + type IpWhitelistStatus, + type ShopKeyItem, + type ShopKeyPageResult, +} from './shop-dto.ts' + +const IP_WHITELIST_VALUES: readonly string[] = ['UNKNOWN', 'ALLOWED', 'BLOCKED'] + +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 +} + +/** 归一白名单状态;未知/缺省一律回 UNKNOWN。 */ +export function normalizeIpWhitelistStatus(value: unknown): IpWhitelistStatus { + const textValue = typeof value === 'string' ? value.trim().toUpperCase() : '' + return (IP_WHITELIST_VALUES.includes(textValue) ? textValue : 'UNKNOWN') as IpWhitelistStatus +} + +/** 解析单条店铺密钥行;缺 id 视为无效。 */ +export function toShopKeyItem(raw: unknown): ShopKeyItem | 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: ShopKeyItem = { + id, + remarkName: text(record.remarkName ?? record.remark_name), + ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name), + ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)), + ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status), + ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message), + } + const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at) + if (checkedAt) item.ipWhitelistCheckedAt = checkedAt + 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 parseShopKeyPage(payload: unknown): ShopKeyPageResult { + const out = emptyShopKeyPageResult() + 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) => toShopKeyItem(raw)) + .filter((item): item is ShopKeyItem => 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 +} diff --git a/admin-frontend-vue/tests/task-82.test.ts b/admin-frontend-vue/tests/task-82.test.ts new file mode 100644 index 00000000..da377db6 --- /dev/null +++ b/admin-frontend-vue/tests/task-82.test.ts @@ -0,0 +1,116 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { parseShopKeyPage, toShopKeyItem } from '../src/pages/shop/shop-key-model.ts' + +test('test_task_082_shop_key_list_load_normal_primary_path', () => { + // 正常主路径:Java ShopKeyPageVo(camel) 封包解析为前端密钥分页结果。 + const page = parseShopKeyPage({ + success: true, + data: { + items: [ + { + id: 9, + remarkName: '主店', + ziniaoAccountName: 'blue_agent', + ziniaoToken: 'token-secret-001', + ipWhitelistStatus: 'BLOCKED', + ipWhitelistCheckedAt: '2026-02-01T09:00:00', + ipWhitelistMessage: 'IP 不在白名单', + 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, 9) + assert.equal(item.remarkName, '主店') + assert.equal(item.ziniaoAccountName, 'blue_agent') + assert.equal(item.ziniaoToken, 'token-secret-001') + assert.equal(item.ipWhitelistStatus, 'BLOCKED') + assert.equal(item.ipWhitelistCheckedAt, '2026-02-01T09:00:00') + assert.equal(item.ipWhitelistMessage, 'IP 不在白名单') + assert.equal(item.createdAt, '2026-01-01T10:00:00') + assert.equal(page.total, 1) + assert.equal(page.page, 1) + assert.equal(page.pageSize, 15) +}) + +test('test_task_082_shop_key_list_load_normal_variant_input', () => { + // 正常变体:已解包 VO 直传;snake 兜底(remark_name);小写状态归一大写。 + const page = parseShopKeyPage({ + items: [{ id: 12, remark_name: '副店', ziniao_token: 't2', ip_whitelist_status: 'allowed' }], + total: 3, + page: 2, + pageSize: 20, + }) + const item = page.items[0] + assert.equal(item.id, 12) + assert.equal(item.remarkName, '副店') + assert.equal(item.ziniaoToken, 't2') + assert.equal(item.ipWhitelistStatus, 'ALLOWED') + assert.equal(item.ipWhitelistCheckedAt, undefined) + assert.equal(item.ipWhitelistMessage, '') + assert.equal(page.page, 2) + assert.equal(page.pageSize, 20) +}) + +test('test_task_082_shop_key_list_load_repeated_is_idempotent', () => { + // 正常重复:解析同一负载结果稳定、不改输入。 + const payload = { data: { items: [{ id: 1, remarkName: 'A', ziniaoToken: 'x' }], total: 1, page: 1, pageSize: 15 } } + assert.deepEqual(parseShopKeyPage(payload), parseShopKeyPage(payload)) + assert.equal((payload as { data: { items: unknown[] } }).data.items.length, 1) +}) + +test('test_task_082_shop_key_list_load_boundary_empty_input', () => { + // 边界空值:空负载回默认分页结果(空列表/total 0/page 1/页大小 15)。 + const page = parseShopKeyPage({}) + assert.deepEqual(page.items, []) + assert.equal(page.total, 0) + assert.equal(page.page, 1) + assert.equal(page.pageSize, 15) +}) + +test('test_task_082_shop_key_list_load_boundary_single_item', () => { + // 边界单元素:单条记录;缺 id 的行被过滤。 + const page = parseShopKeyPage({ + data: { items: [{ id: 7, remarkName: 'Z', ziniaoToken: 'z' }, { remarkName: 'no-id' }], total: 2, page: 1, pageSize: 15 }, + }) + assert.equal(page.items.length, 1) + assert.equal(page.items[0].id, 7) +}) + +test('test_task_082_shop_key_list_load_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:缺省字段回默认值;未知/缺白名单状态回 UNKNOWN;最小行可用。 + const minimal = toShopKeyItem({ id: 3 }) + assert.ok(minimal) + assert.equal(minimal.id, 3) + assert.equal(minimal.remarkName, '') + assert.equal(minimal.ziniaoToken, '') + assert.equal(minimal.ipWhitelistStatus, 'UNKNOWN') + assert.equal(minimal.createdAt, undefined) + const unknownStatus = toShopKeyItem({ id: 4, ziniaoToken: 't', ipWhitelistStatus: 'WEIRD' }) + assert.equal(unknownStatus!.ipWhitelistStatus, 'UNKNOWN') +}) + +test('test_task_082_shop_key_list_load_invalid_input_rejected', () => { + // 异常输入:success=false 抛后端 message;非对象/非数字 id 行不入列表。 + assert.throws(() => parseShopKeyPage({ success: false, message: '无权访问店铺密钥' }), /无权访问/) + assert.equal(toShopKeyItem('garbage'), null) + assert.equal(toShopKeyItem({ remarkName: 'no id' }), null) +}) + +test('test_task_082_shop_key_list_load_dependency_failure_returns_actionable_message', () => { + // 依赖失败/加载走 adapter:GET /api/admin/shop-keys + http.get + 解析,页面不内联。 + const api = readSource('src/pages/shop/shop-key-api.ts') + assert.match(api, /\/api\/admin\/shop-keys/) + assert.match(api, /http\.get/) + assert.match(api, /parseShopKeyPage/) + const model = readSource('src/pages/shop/shop-key-model.ts') + assert.equal(/axios|http\./.test(model), false, '店铺密钥列表解析保持纯逻辑') +})