task-81(店铺中心): 定义店铺和店铺密钥 DTO
新增 src/pages/shop/shop-dto.ts 纯逻辑 DTO:ShopKeyItem/ShopSummary/ ShopCredential/SensitiveString 等类型边界与分页归一/查询序列化(店铺接口 camelCase);凭证明文与紫鸟令牌按敏感字段建模,禁止进入通用日志。 TDD: task-81.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
@@ -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>): ShopKeyListParams {
|
||||||
|
return normalizePageCore(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 店铺列表分页/筛选归一:负/缺分组 id 视为无筛选,空白店铺名视为无过滤。 */
|
||||||
|
export function normalizeShopListParams(raw: Partial<ShopManageListParams>): 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 }
|
||||||
|
}
|
||||||
@@ -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 字段名')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user