feat(order-service): 优化资料字段预设与游戏ID校验

This commit is contained in:
yml2213
2026-11-04 12:33:33 +08:00
parent e17f336e1c
commit 1a80521057
21 changed files with 377 additions and 37 deletions
@@ -235,7 +235,12 @@ export async function backfillPendingOrderServiceSnapshotsForCategory(
AND wo.category_id = p.category_id
AND wo.status = 'pending_material'
AND wo.assigned_worker_id IS NULL
AND wo.service_profile_snapshot_json IS NULL
AND NOT EXISTS (
SELECT 1
FROM work_order_shares share
WHERE share.work_order_id = wo.id
AND share.status <> 'cancelled'
)
`,
[input.categoryId, input.profileId, input.versionId],
)
@@ -15,6 +15,7 @@ import {
resolveRequirementFields,
} from './mappers.js'
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
import { findInvalidSubmittedFields } from './work-order-material-link-service.js'
import { getRequiredWorkOrder } from './worker-session-context-service.js'
export async function submitAdminWorkOrderMaterial(
@@ -75,6 +76,14 @@ async function persistAdminWorkOrderMaterial(workOrderId: number | string, paylo
const fields = resolveRequirementFields(workOrder)
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
const invalidDigits = findInvalidSubmittedFields(fields, submittedFields)
if (invalidDigits.length > 0) {
throw createHttpError(`${invalidDigits.map((field) => field.label).join('、')}只能填写数字`, {
statusCode: 400,
errorCode: 'work_order_material_fields_invalid',
context: { invalidFields: invalidDigits.map((field) => field.key) },
})
}
const complete = fields.every(
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
)
@@ -5,6 +5,7 @@ import {
isOrderServiceMaterialRequired,
normalizeProfileConfig,
} from './order-service-profile-service.js'
import { backfillPendingOrderServiceSnapshotsForCategory } from '../../repositories/worker-platform/order-service-profile-repo.js'
function config(patch: Record<string, unknown> = {}) {
return normalizeProfileConfig({
@@ -78,6 +79,13 @@ test('订单服务配置规范化:未知字段类型降级为文本,空下
assert.equal(normalized.fields[0]?.type, 'text')
})
test('订单服务游戏 ID:标准化为纯数字字符串校验', () => {
const normalized = config({
fields: [{ key: 'gameId', label: '游戏 ID', type: 'text', required: true }],
})
assert.equal(normalized.fields[0]?.validation, 'digits')
})
test('订单服务提交确认:旧配置默认展示不可修改与资料错误免责声明', () => {
const normalized = config()
assert.deepEqual(normalized.materialConfirmation, {
@@ -108,3 +116,23 @@ test('订单服务提交确认:支持自定义标题、正文和按钮文字',
cancelText: '再检查一下',
})
})
test('发布配置:待提交且无有效拼单份额的工单可更新到最新快照', async () => {
let capturedSql = ''
const client = {
query: async (sql: string) => {
capturedSql = sql
return { rowCount: 2 }
},
} as unknown as Parameters<typeof backfillPendingOrderServiceSnapshotsForCategory>[0]
const count = await backfillPendingOrderServiceSnapshotsForCategory(client, {
categoryId: 1,
profileId: 1,
versionId: 14,
})
assert.equal(count, 2)
assert.doesNotMatch(capturedSql, /service_profile_snapshot_json IS NULL/)
assert.match(capturedSql, /share\.status <> 'cancelled'/)
})
@@ -20,11 +20,13 @@ import { listAllWorkCategories } from '../../repositories/worker-platform/work-c
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
export type OrderServiceFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
export type OrderServiceFieldValidation = 'none' | 'digits'
export type OrderServiceField = {
key: string
label: string
type: OrderServiceFieldType
validation: OrderServiceFieldValidation
required: boolean
placeholder: string
helpText: string
@@ -223,7 +225,7 @@ function mapAdminProfile(row: OrderServiceProfileRow) {
title: row.title,
description: row.description,
guideImage: row.guide_image_json,
fields: row.fields_json,
fields: buildProfileConfig(row).fields,
screenshots: row.screenshots_json,
autoPublish: row.auto_publish_json,
materialConfirmation: normalizeMaterialConfirmation(row.material_confirmation_json),
@@ -328,6 +330,8 @@ function normalizeFields(value: unknown): OrderServiceField[] {
const type = (
FIELD_TYPES.has(rawType as OrderServiceFieldType) ? rawType : 'text'
) as OrderServiceFieldType
const validation: OrderServiceFieldValidation =
source.validation === 'digits' || key === 'gameId' ? 'digits' : 'none'
const options = Array.isArray(source.options)
? source.options.map((option) => text(option, 60)).filter(Boolean)
: []
@@ -336,6 +340,7 @@ function normalizeFields(value: unknown): OrderServiceField[] {
key,
label: text(source.label, 60) || key,
type,
validation,
required: source.required !== false,
placeholder: text(source.placeholder, 100),
helpText: text(source.helpText ?? source.help_text, 200),
@@ -347,6 +352,7 @@ function normalizeFields(value: unknown): OrderServiceField[] {
key,
label: text(source.label, 60) || key,
type,
validation,
required: source.required !== false,
placeholder: '',
helpText: text(source.helpText ?? source.help_text, 200),
@@ -26,6 +26,7 @@ import {
} from '../file-storage/file-storage-service.js'
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
import {
findInvalidSubmittedFields,
pickSubmittedMaterialFields,
resolvePublicMaterialOrderState,
throwPublicMaterialBlocked,
@@ -160,6 +161,14 @@ export async function submitOrderServiceMaterial(
},
)
}
const invalidDigits = findInvalidSubmittedFields(fields, submittedFields)
if (invalidDigits.length > 0) {
throw createHttpError(`${invalidDigits.map((field) => field.label).join('、')}只能填写数字`, {
statusCode: 400,
errorCode: 'material_link_fields_invalid',
context: { invalidFields: invalidDigits.map((field) => field.key) },
})
}
const screenshotRule = profileConfig.screenshots
const requestedScreenshots = screenshotRule.enabled
@@ -421,7 +430,10 @@ async function resolveProfileConfig(
title: '',
description: '',
guideImage: {},
fields: legacy.fields as OrderServiceField[],
fields: normalizeProfileConfig({
name: '通用资料',
fields: legacy.fields,
}).fields,
screenshots: {
enabled: true,
required: true,
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
findInvalidSubmittedFields,
formatPublicMaterialStatus,
pickSubmittedMaterialFields,
resolveMaterialResetBlockReason,
@@ -139,3 +140,17 @@ test('公开补资料:提交字段只保留 requirements 白名单,额外字
assert.deepEqual(result, { gameId: '123', gameNickname: '张三' })
})
test('纯数字字段:只接受 ASCII 数字字符串,可选字段留空不报错', () => {
const fields = [
{ key: 'gameId', label: '游戏 ID', validation: 'digits' as const },
{ key: 'backupId', label: '备用 ID', validation: 'digits' as const },
]
assert.deepEqual(findInvalidSubmittedFields(fields, { gameId: '001234567890' }), [])
assert.deepEqual(
findInvalidSubmittedFields(fields, { gameId: '12A', backupId: '123' }).map(
(field) => field.key,
),
['gameId', 'backupId'],
)
})
@@ -112,6 +112,16 @@ export function pickSubmittedMaterialFields(
return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key)))
}
export function findInvalidSubmittedFields(
fields: Array<{ key: string; label: string; validation?: 'none' | 'digits' }>,
submitted: Record<string, string>,
) {
return fields.filter((field) => {
const value = String(submitted[field.key] || '').trim()
return field.validation === 'digits' && Boolean(value) && !/^\d+$/.test(value)
})
}
export function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
if (state.blockedReason === 'already_submitted') {
throw createHttpError(state.message, {
@@ -33,7 +33,12 @@ export function extractConsignmentBuyerInfo(input: {
fields.gameNickname || fields.roleName || fields.nickname || fields.game_nickname || '',
).trim()
const gameId = String(
fields.gameId || fields.characterId || fields.uid || fields.game_id || '',
fields.gameId ||
fields.characterId ||
fields.uid ||
fields.game_id ||
(/^\d+$/.test(String(fields.gameAccount || '').trim()) ? fields.gameAccount : '') ||
'',
).trim()
const serverZone = String(fields.serverZone || fields.zone || fields.server_zone || '').trim()
const system = String(fields.system || fields.os || '').trim()
@@ -7,6 +7,7 @@ export type RequirementField = {
label: string
required: boolean
type: 'text' | 'select'
validation: 'none' | 'digits'
options: string[]
mockValue: string
}
@@ -15,10 +16,10 @@ export function resolveRequirementFields(workOrder: WorkOrderRow) {
return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields)
}
export function normalizeRequirementFields(value: unknown) {
export function normalizeRequirementFields(value: unknown): RequirementField[] {
const rawFields = Array.isArray(value) ? value : []
const fields = rawFields
.map((item) => {
.map((item): RequirementField | null => {
const source =
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
const key = String(source.key || '').trim()
@@ -27,18 +28,21 @@ export function normalizeRequirementFields(value: unknown) {
const options = Array.isArray(source.options)
? source.options.map((option) => String(option || '').trim()).filter(Boolean)
: []
const validation: RequirementField['validation'] =
source.validation === 'digits' || key === 'gameId' ? 'digits' : 'none'
return {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
type,
validation,
options,
mockValue: String(source.mockValue || '').trim(),
}
})
.filter((item): item is RequirementField => Boolean(item))
if (fields.length > 0 && !isDefaultTemplateRequirementFields(fields)) {
if (fields.length > 0) {
return fields
}
return [
@@ -47,6 +51,7 @@ export function normalizeRequirementFields(value: unknown) {
label: '游戏编号',
required: true,
type: 'text',
validation: 'digits',
options: [],
mockValue: 'test_uid',
},
@@ -55,6 +60,7 @@ export function normalizeRequirementFields(value: unknown) {
label: '游戏昵称',
required: true,
type: 'text',
validation: 'none',
options: [],
mockValue: '测试角色',
},
@@ -63,6 +69,7 @@ export function normalizeRequirementFields(value: unknown) {
label: '系统',
required: false,
type: 'select',
validation: 'none',
options: ['安卓', '苹果'],
mockValue: '安卓',
},
@@ -71,19 +78,13 @@ export function normalizeRequirementFields(value: unknown) {
label: '区服',
required: false,
type: 'select',
validation: 'none',
options: ['QQ区', '微信区'],
mockValue: 'QQ区',
},
]
}
function isDefaultTemplateRequirementFields(fields: RequirementField[]): boolean {
if (fields.length === 0) return true
const defaultKeys = new Set(['gameId', 'gameNickname', 'system', 'serverZone'])
const legacyKeys = new Set(['gameAccount', 'serverName', 'roleName'])
return fields.every((field) => defaultKeys.has(field.key) || legacyKeys.has(field.key))
}
export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
const directFields = Array.isArray(payload.fields) ? payload.fields : null
const requirement =
@@ -105,7 +106,7 @@ export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
return textFields.length > 0 ? textFields : normalizeRequirementFields([])
}
export function normalizeRequirementFieldsText(value: unknown) {
export function normalizeRequirementFieldsText(value: unknown): RequirementField[] {
return String(value || '')
.split(/\r?\n/)
.map((line, index) => {
@@ -123,6 +124,7 @@ export function normalizeRequirementFieldsText(value: unknown) {
label: label || key,
required: true,
type: rawOptions.length > 0 ? 'select' : 'text',
validation: key === 'gameId' ? 'digits' : 'none',
options: rawOptions,
mockValue: '',
}
@@ -133,6 +135,7 @@ export function normalizeRequirementFieldsText(value: unknown) {
label: plainLabel,
required: true,
type: plainOptions.length > 0 ? 'select' : 'text',
validation: 'none',
options: plainOptions,
mockValue: '',
}
@@ -1599,6 +1599,15 @@ test('normalizeRequirementFields default template marks serverZone optional with
assert.deepEqual(serverZone?.options, ['QQ区', '微信区'])
})
test('normalizeRequirementFields 只保留运营实际选择的字段,不把部分默认字段扩成四件套', () => {
const fields = normalizeRequirementFields([
{ key: 'gameId', label: '游戏 ID', type: 'text', required: true },
])
assert.equal(fields.length, 1)
assert.equal(fields[0]?.key, 'gameId')
assert.equal(fields[0]?.validation, 'digits')
})
test('resolveSkuNameQuantity parses arabic quantities from sku names', () => {
assert.equal(resolveSkuNameQuantity('指挥官秘钥1个'), 1)
assert.equal(resolveSkuNameQuantity('指挥官秘钥15个'), 15)