From 93033e94db2e8f1fb9ee8afe0af376bb7f2fac5f 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:32:08 +0800 Subject: [PATCH] =?UTF-8?q?task-81(=E5=BA=97=E9=93=BA=E4=B8=AD=E5=BF=83):?= =?UTF-8?q?=20=E5=AE=9A=E4=B9=89=E5=BA=97=E9=93=BA=E5=92=8C=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E5=AF=86=E9=92=A5=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/pages/shop/shop-dto.ts 纯逻辑 DTO:ShopKeyItem/ShopSummary/ ShopCredential/SensitiveString 等类型边界与分页归一/查询序列化(店铺接口 camelCase);凭证明文与紫鸟令牌按敏感字段建模,禁止进入通用日志。 TDD: task-81.test.ts 8 用例先 RED 后 GREEN。 --- admin-frontend-vue/src/pages/shop/shop-dto.ts | 164 ++++++++++++++++++ admin-frontend-vue/tests/task-81.test.ts | 118 +++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 admin-frontend-vue/src/pages/shop/shop-dto.ts create mode 100644 admin-frontend-vue/tests/task-81.test.ts diff --git a/admin-frontend-vue/src/pages/shop/shop-dto.ts b/admin-frontend-vue/src/pages/shop/shop-dto.ts new file mode 100644 index 00000000..c98f3292 --- /dev/null +++ b/admin-frontend-vue/src/pages/shop/shop-dto.ts @@ -0,0 +1,164 @@ +/** 店铺中心共享 DTO(任务 81):店铺密钥/店铺摘要/明文凭据/分组与分页类型; + * 纯逻辑、无框架依赖,与 Java shopkey 模块的 camelCase 接口契约对齐。 */ + +export const SHOP_PAGE_DEFAULT_SIZE = 15 +export const SHOP_PAGE_MIN_PAGE = 1 +export const SHOP_PAGE_MAX_SIZE = 100 + +/** 敏感字符串品牌:紫鸟令牌、店铺密码等明文只允许经 toSensitiveString 标记,禁止直接进入通用日志/上报。 */ +export type SensitiveString = string & { readonly __sensitiveBrand: unique symbol } + +export function toSensitiveString(value: string): SensitiveString { + return value as SensitiveString +} + +/** 紫鸟 IP 白名单状态。 */ +export type IpWhitelistStatus = 'UNKNOWN' | 'ALLOWED' | 'BLOCKED' + +/** 店铺密钥行(GET /api/admin/shop-keys data.items;Java ShopKeyItemVo)。 */ +export interface ShopKeyItem { + id: number + remarkName: string + ziniaoAccountName: string + /** 紫鸟令牌:敏感字段,列表展示必须掩码。 */ + ziniaoToken: SensitiveString + ipWhitelistStatus: IpWhitelistStatus + ipWhitelistCheckedAt?: string + ipWhitelistMessage?: string + createdAt?: string + updatedAt?: string +} + +export interface ShopKeyPageResult { + items: ShopKeyItem[] + total: number + page: number + pageSize: number +} + +/** 店铺行(GET /api/admin/shop-manages data.items;Java ShopManageItemVo,密码只下发掩码)。 */ +export interface ShopSummary { + id: number + groupId: number | null + groupName: string + shopName: string + mallName: string + znUsername: string + account: string + passwordMasked?: string + createdAt?: string + updatedAt?: string +} + +export interface ShopPageResult { + items: ShopSummary[] + total: number + page: number + pageSize: number +} + +/** 店铺明文凭据(GET /api/admin/shop-manages/{id}/credential data;Java ShopManageCredentialVo)。 */ +export interface ShopCredential { + id: number + groupId: number | null + groupName: string + shopName: string + mallName: string + znUsername: string + account: string + password: SensitiveString +} + +/** 店铺分组下拉项(GET /api/admin/shop-manages/groups data)。 */ +export interface ShopGroupOption { + id: number + groupName: string + memberCount?: number + leaderUsername?: string +} + +/** 店铺密钥列表分页参数(该接口只支持分页)。 */ +export interface ShopKeyListParams { + page: number + pageSize: number +} + +/** 店铺列表分页/筛选参数。 */ +export interface ShopManageListParams { + page: number + pageSize: number + groupId?: number | null + shopName?: string +} + +export interface ShopKeyPageQuery { + page: number + pageSize: number +} + +export interface ShopManagePageQuery { + page: number + pageSize: number + groupId?: number + shopName?: string +} + +function finiteInt(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null +} + +function positiveIdOrNull(value: unknown): number | null { + const id = finiteInt(value) + return id === null || id < 1 ? null : id +} + +function trimOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +/** 归一化分页:页码下限 1、页大小钳制在 1..100(与 Java 服务端一致)。 */ +function normalizePageCore(raw: { page?: unknown; pageSize?: unknown }): { page: number; pageSize: number } { + const page = Math.max(finiteInt(raw.page) ?? SHOP_PAGE_MIN_PAGE, SHOP_PAGE_MIN_PAGE) + const rawSize = finiteInt(raw.pageSize) + const pageSize = + rawSize === null || rawSize < SHOP_PAGE_MIN_PAGE + ? SHOP_PAGE_DEFAULT_SIZE + : Math.min(rawSize, SHOP_PAGE_MAX_SIZE) + return { page, pageSize } +} + +/** 店铺密钥列表分页归一。 */ +export function normalizeKeyListParams(raw: Partial): ShopKeyListParams { + return normalizePageCore(raw) +} + +/** 店铺列表分页/筛选归一:负/缺分组 id 视为无筛选,空白店铺名视为无过滤。 */ +export function normalizeShopListParams(raw: Partial): ShopManageListParams { + return { + ...normalizePageCore(raw), + groupId: positiveIdOrNull(raw.groupId), + shopName: trimOrUndefined(raw.shopName), + } +} + +export function toKeyPageQuery(params: ShopKeyListParams): ShopKeyPageQuery { + return { page: params.page, pageSize: params.pageSize } +} + +/** 前端状态 → Java 查询参数(camelCase);仅下发非空可选字段。 */ +export function toShopManagePageQuery(params: ShopManageListParams): ShopManagePageQuery { + const query: ShopManagePageQuery = { page: params.page, pageSize: params.pageSize } + if (params.groupId != null) query.groupId = params.groupId + if (params.shopName) query.shopName = params.shopName + return query +} + +/** 无数据密钥分页缺省值(首屏/加载前占位)。 */ +export function emptyShopKeyPageResult(): ShopKeyPageResult { + return { items: [], total: 0, page: SHOP_PAGE_MIN_PAGE, pageSize: SHOP_PAGE_DEFAULT_SIZE } +} + +/** 无数据店铺分页缺省值(首屏/加载前占位)。 */ +export function emptyShopPageResult(): ShopPageResult { + return { items: [], total: 0, page: SHOP_PAGE_MIN_PAGE, pageSize: SHOP_PAGE_DEFAULT_SIZE } +} diff --git a/admin-frontend-vue/tests/task-81.test.ts b/admin-frontend-vue/tests/task-81.test.ts new file mode 100644 index 00000000..62eeecdf --- /dev/null +++ b/admin-frontend-vue/tests/task-81.test.ts @@ -0,0 +1,118 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + SHOP_PAGE_DEFAULT_SIZE, + SHOP_PAGE_MAX_SIZE, + SHOP_PAGE_MIN_PAGE, + emptyShopKeyPageResult, + emptyShopPageResult, + normalizeKeyListParams, + normalizeShopListParams, + toKeyPageQuery, + toShopManagePageQuery, +} from '../src/pages/shop/shop-dto.ts' + +test('test_task_081_shop_dto_normal_primary_path', () => { + // 正常主路径:完整店铺筛选/分页归一后序列化为 Java camelCase 查询参数(page/pageSize/groupId/shopName)。 + const params = normalizeShopListParams({ + page: 2, + pageSize: 20, + groupId: 3, + shopName: ' 主 店 ', + }) + assert.equal(params.page, 2) + assert.equal(params.pageSize, 20) + assert.equal(params.groupId, 3) + assert.equal(params.shopName, '主 店') + const query = toShopManagePageQuery(params) + assert.equal(query.page, 2) + assert.equal(query.pageSize, 20) + assert.equal(query.groupId, 3) + assert.equal(query.shopName, '主 店') +}) + +test('test_task_081_shop_dto_normal_variant_input', () => { + // 正常变体:店铺密钥列表只允许 page/pageSize,不携带分组/店铺名维度。 + const params = normalizeKeyListParams({ page: 3, pageSize: 30 }) + const query = toKeyPageQuery(params) + assert.deepEqual(Object.keys(query).sort(), ['page', 'pageSize']) + assert.equal(query.page, 3) + assert.equal(query.pageSize, 30) + // 店铺列表可选条件缺省时不下发对应字段。 + const manageQuery = toShopManagePageQuery(normalizeShopListParams({ page: 1, pageSize: 15 })) + assert.deepEqual(Object.keys(manageQuery).sort(), ['page', 'pageSize']) +}) + +test('test_task_081_shop_dto_normal_repeated_operation_is_idempotent', () => { + // 正常重复:归一不改输入、结果稳定;空结果默认值相互独立。 + const input = { page: 2, pageSize: 25, groupId: 7, shopName: 'A店' } + assert.deepEqual(normalizeShopListParams(input), normalizeShopListParams(input)) + assert.equal(input.shopName, 'A店') + const first = emptyShopPageResult() + const second = emptyShopPageResult() + first.items.push({} as never) + assert.equal(second.items.length, 0, '每次空结果返回独立列表,避免污染') +}) + +test('test_task_081_shop_dto_boundary_empty_input', () => { + // 边界空值:空入参回默认分页(第 1 页/15 条),无可选筛选。 + const params = normalizeShopListParams({}) + assert.equal(params.page, SHOP_PAGE_MIN_PAGE) + assert.equal(params.pageSize, SHOP_PAGE_DEFAULT_SIZE) + assert.equal(params.groupId, null) + assert.equal(params.shopName, undefined) + const query = toShopManagePageQuery(params) + assert.deepEqual(Object.keys(query).sort(), ['page', 'pageSize']) +}) + +test('test_task_081_shop_dto_boundary_single_item', () => { + // 边界单元素:页大小 1 保留为最小单行页;单行空结果 DTO 结构完整。 + assert.equal(normalizeShopListParams({ page: 1, pageSize: 1 }).pageSize, 1) + const key = emptyShopKeyPageResult() + assert.deepEqual(key.items, []) + assert.equal(key.total, 0) + assert.equal(key.page, SHOP_PAGE_MIN_PAGE) + assert.equal(key.pageSize, SHOP_PAGE_DEFAULT_SIZE) + const shop = emptyShopPageResult() + assert.deepEqual(shop.items, []) + assert.equal(shop.total, 0) +}) + +test('test_task_081_shop_dto_boundary_limit_or_missing_field', () => { + // 边界上限:页码 <1 归 1;页大小超上限/非正被夹取;负分组 id 视为无筛选。 + assert.equal(normalizeShopListParams({ page: 0, pageSize: 999 }).page, 1) + assert.equal(normalizeShopListParams({ page: 0, pageSize: 999 }).pageSize, SHOP_PAGE_MAX_SIZE) + assert.equal(normalizeShopListParams({ page: 0, pageSize: 0 }).pageSize, SHOP_PAGE_DEFAULT_SIZE) + assert.equal(normalizeShopListParams({ page: 1, pageSize: 15, groupId: -5 }).groupId, null) + const params = normalizeShopListParams({ page: 1, pageSize: 15, groupId: undefined }) + assert.equal(params.shopName, undefined) +}) + +test('test_task_081_shop_dto_invalid_input_rejected', () => { + // 异常输入:非数字/NaN/空白筛选取缺省;字符串分组 id 不接受。 + const params = normalizeShopListParams({ + page: 'x' as never, + pageSize: Number.NaN, + groupId: '3' as never, + shopName: ' ', + }) + assert.equal(params.page, SHOP_PAGE_MIN_PAGE) + assert.equal(params.pageSize, SHOP_PAGE_DEFAULT_SIZE) + assert.equal(params.groupId, null) + assert.equal(params.shopName, undefined) +}) + +test('test_task_081_shop_dto_dependency_failure_returns_actionable_message', () => { + // 依赖失败/可操作:DTO 为纯逻辑、无框架/http;敏感字段与店铺/密钥边界有明确类型。 + const mod = readSource('src/pages/shop/shop-dto.ts') + assert.equal(/axios|http\.|from '\.\/http'|vue/.test(mod), false, 'DTO 模块保持纯逻辑') + assert.match(mod, /export interface ShopKeyItem/) + assert.match(mod, /export interface ShopSummary/) + assert.match(mod, /export interface ShopCredential/) + assert.match(mod, /export type SensitiveString/) + assert.match(mod, /ziniaoToken\s*:/, '紫鸟令牌以敏感字段建模') + assert.match(mod, /password\s*:\s*SensitiveString/, '凭证明文必须落在 SensitiveString') + assert.match(mod, /passwordMasked\s*\?/, '列表行只有掩码密码') + assert.equal(/group_id|created_by_id/.test(mod), false, '店铺接口为 camelCase,不应出现 snake 字段名') +})