task-84(店铺中心): 实现新增店铺密钥表单

新增 shop-key-form-model.ts(备注/紫鸟账号/令牌校验与 camel 请求体序列化,
镜像 Java NotBlank/Size 规则,令牌去 Bearer 前缀) 并在 shop-key-api.ts 增加
submitCreateShopKey(POST /api/admin/shop-keys)。

TDD: task-84.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
2026-09-05 16:38:02 +08:00
parent 119095a57b
commit 72a8f75dc5
3 changed files with 197 additions and 3 deletions
@@ -1,7 +1,19 @@
/** 店铺密钥列表加载适配(任务 82):GET /api/admin/shop-keys + 分页归一与解析。 */ /** 店铺密钥列表加载与新增适配(任务 82/84):GET/POST /api/admin/shop-keys + 归一与解析。 */
import { http } from '@/api/http' import { http } from '@/api/http'
import { parseShopKeyPage } from './shop-key-model' import { unwrap } from '@/api/envelope'
import { normalizeKeyListParams, toKeyPageQuery, type ShopKeyListParams, type ShopKeyPageResult } from './shop-dto.ts' import { parseShopKeyPage, toShopKeyItem } from './shop-key-model.ts'
import {
normalizeKeyListParams,
toKeyPageQuery,
type ShopKeyItem,
type ShopKeyListParams,
type ShopKeyPageResult,
} from './shop-dto.ts'
import {
toShopKeyCreateRequest,
validateShopKeyForm,
type ShopKeyFormValues,
} from './shop-key-form-model.ts'
export const SHOP_KEYS_ENDPOINT = '/api/admin/shop-keys' export const SHOP_KEYS_ENDPOINT = '/api/admin/shop-keys'
@@ -10,3 +22,17 @@ export async function fetchShopKeyList(params: Partial<ShopKeyListParams> = {}):
const { data } = await http.get<unknown>(SHOP_KEYS_ENDPOINT, { params: toKeyPageQuery(normalized) }) const { data } = await http.get<unknown>(SHOP_KEYS_ENDPOINT, { params: toKeyPageQuery(normalized) })
return parseShopKeyPage(data) return parseShopKeyPage(data)
} }
/** 提交新增店铺密钥:先校验(失败抛首条错误不发请求),再 POST,解析返回的新记录。 */
export async function submitCreateShopKey(form: ShopKeyFormValues): Promise<ShopKeyItem> {
const { valid, errors } = validateShopKeyForm(form)
if (!valid) {
throw new Error(Object.values(errors)[0] || '店铺密钥表单校验未通过')
}
const { data } = await http.post<unknown>(SHOP_KEYS_ENDPOINT, toShopKeyCreateRequest(form))
const item = toShopKeyItem(unwrap<unknown>(data))
if (!item) {
throw new Error('新增店铺密钥响应异常:未返回有效记录')
}
return item
}
@@ -0,0 +1,76 @@
/** 店铺密钥表单模型(任务 84/85 共用):新增/编辑都镜像 Java ShopKeyCreateRequest/UpdateRequest 的
* 字段、长度校验与敏感字段语义;纯逻辑,无框架依赖。 */
export const SHOP_KEY_REMARK_MAX_LEN = 128
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
export const SHOP_KEY_TOKEN_MAX_LEN = 512
export interface ShopKeyFormValues {
remarkName: string
ziniaoAccountName: string
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
ziniaoToken: string
}
export interface ShopKeyFormErrors {
remarkName?: string
ziniaoAccountName?: string
ziniaoToken?: string
}
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
export interface ShopKeyPayload {
remarkName?: string
ziniaoAccountName: string
ziniaoToken: string
}
export function emptyShopKeyForm(): ShopKeyFormValues {
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '' }
}
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
export function normalizeZiniaoToken(value: string): string {
let token = (value || '').trim()
if (token.toUpperCase().startsWith('BEARER ')) {
token = token.slice(7).trim()
}
return token
}
function remarkTrimmed(value: string): string {
return (value || '').trim()
}
/** 校验新增/编辑表单(镜像后端 NotBlank/Size 规则),返回逐字段错误。 */
export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean; errors: ShopKeyFormErrors } {
const errors: ShopKeyFormErrors = {}
const remark = remarkTrimmed(form.remarkName)
if (remark.length > SHOP_KEY_REMARK_MAX_LEN) {
errors.remarkName = `备注名长度不能超过${SHOP_KEY_REMARK_MAX_LEN}个字符`
}
const account = (form.ziniaoAccountName || '').trim()
if (!account) {
errors.ziniaoAccountName = '紫鸟账号名称不能为空'
} else if (account.length > SHOP_KEY_ACCOUNT_MAX_LEN) {
errors.ziniaoAccountName = `紫鸟账号名称长度不能超过${SHOP_KEY_ACCOUNT_MAX_LEN}个字符`
}
const token = normalizeZiniaoToken(form.ziniaoToken)
if (!token) {
errors.ziniaoToken = '紫鸟令牌不能为空'
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** 新增表单 → 创建请求体:账号/令牌归一,备注为空则不下发。 */
export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload {
const remark = remarkTrimmed(form.remarkName)
const payload: ShopKeyPayload = {
ziniaoAccountName: (form.ziniaoAccountName || '').trim(),
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
}
if (remark) payload.remarkName = remark
return payload
}
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
SHOP_KEY_ACCOUNT_MAX_LEN,
SHOP_KEY_REMARK_MAX_LEN,
SHOP_KEY_TOKEN_MAX_LEN,
emptyShopKeyForm,
toShopKeyCreateRequest,
validateShopKeyForm,
} from '../src/pages/shop/shop-key-form-model.ts'
test('test_task_084_shop_key_create_form_normal_primary_path', () => {
// 正常主路径:合法表单校验通过并序列化为 Java camelCase 请求体。
const form = emptyShopKeyForm()
form.remarkName = '主店'
form.ziniaoAccountName = 'blue_agent'
form.ziniaoToken = 'token-secret-1'
const { valid, errors } = validateShopKeyForm(form)
assert.equal(valid, true)
assert.deepEqual(errors, {})
assert.deepEqual(toShopKeyCreateRequest(form), {
remarkName: '主店',
ziniaoAccountName: 'blue_agent',
ziniaoToken: 'token-secret-1',
})
})
test('test_task_084_shop_key_create_form_normal_variant_input', () => {
// 正常变体:可选备注留空不下发;令牌首尾空白去除。
const request = toShopKeyCreateRequest({
remarkName: ' ',
ziniaoAccountName: ' red_agent ',
ziniaoToken: ' tok-2 ',
})
assert.equal('remarkName' in request, false)
assert.equal(request.ziniaoAccountName, 'red_agent')
assert.equal(request.ziniaoToken, 'tok-2')
})
test('test_task_084_shop_key_create_form_repeated_is_idempotent', () => {
// 正常重复:序列化不改输入、结果稳定。
const form = { remarkName: 'A', ziniaoAccountName: 'acc', ziniaoToken: 'tok' }
assert.deepEqual(toShopKeyCreateRequest(form), toShopKeyCreateRequest(form))
assert.deepEqual(form, { remarkName: 'A', ziniaoAccountName: 'acc', ziniaoToken: 'tok' })
})
test('test_task_084_shop_key_create_form_boundary_empty_input', () => {
// 边界空值:空表单校验给出账号/令牌缺失错误,备注可选不报错。
const { valid, errors } = validateShopKeyForm(emptyShopKeyForm())
assert.equal(valid, false)
assert.equal(errors.ziniaoAccountName, '紫鸟账号名称不能为空')
assert.equal(errors.ziniaoToken, '紫鸟令牌不能为空')
assert.equal('remarkName' in errors, false)
})
test('test_task_084_shop_key_create_form_boundary_single_item', () => {
// 边界单元素:仅账号+令牌的最短合法表单通过。
const { valid } = validateShopKeyForm({ remarkName: '', ziniaoAccountName: 'a', ziniaoToken: 't' })
assert.equal(valid, true)
})
test('test_task_084_shop_key_create_form_boundary_limit_or_missing_field', () => {
// 边界上限:各字段长度上限校验与超限拒绝。
const long = 'x'.repeat(SHOP_KEY_REMARK_MAX_LEN + 1)
const account = 'a'.repeat(SHOP_KEY_ACCOUNT_MAX_LEN)
const token = 't'.repeat(SHOP_KEY_TOKEN_MAX_LEN)
const ok = validateShopKeyForm({ remarkName: 'x'.repeat(SHOP_KEY_REMARK_MAX_LEN), ziniaoAccountName: account, ziniaoToken: token })
assert.equal(ok.valid, true)
const over = validateShopKeyForm({ remarkName: long, ziniaoAccountName: account, ziniaoToken: token })
assert.equal(over.valid, false)
assert.match(over.errors.remarkName || '', /128/)
})
test('test_task_084_shop_key_create_form_invalid_input_rejected', () => {
// 异常输入:空白令牌/账号拒绝;超长备注给出可操作消息。
const { errors } = validateShopKeyForm({ remarkName: '', ziniaoAccountName: ' ', ziniaoToken: ' ' })
assert.equal(errors.ziniaoAccountName, '紫鸟账号名称不能为空')
assert.equal(errors.ziniaoToken, '紫鸟令牌不能为空')
})
test('test_task_084_shop_key_create_form_dependency_failure_returns_actionable_message', () => {
// 依赖失败/提交走 adapter:模型纯逻辑,adapter POST /api/admin/shop-keys + 校验。
const model = readSource('src/pages/shop/shop-key-form-model.ts')
assert.equal(/axios|http\.|vue/.test(model), false, '表单模型保持纯逻辑')
assert.match(model, /SensitiveString|敏感/)
const api = readSource('src/pages/shop/shop-key-api.ts')
assert.match(api, /submitCreateShopKey/)
assert.match(api, /http\.post/)
assert.match(api, /\/api\/admin\/shop-keys/)
assert.match(api, /validateShopKeyForm/)
})