diff --git a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts index 3bccc07d..60d437c7 100644 --- a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts +++ b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts @@ -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], ) diff --git a/apps/backend/src/services/worker-platform/admin-work-order-material-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-material-service.ts index 8d130999..e368ba45 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-material-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-material-service.ts @@ -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(), ) diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts index 8be0a8aa..9486b0e6 100644 --- a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts @@ -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 = {}) { 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[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'/) +}) diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.ts index 0e19a2b1..949e239f 100644 --- a/apps/backend/src/services/worker-platform/order-service-profile-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.ts @@ -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), diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index 254be8c1..605ac8c4 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts index fb530e4b..bc669fb7 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -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'], + ) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index ef6d085d..dae08d52 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -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, +) { + 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, { diff --git a/apps/backend/src/services/worker-platform/work-order-material-mappers.ts b/apps/backend/src/services/worker-platform/work-order-material-mappers.ts index aaa2088c..49ebefa2 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-mappers.ts @@ -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() diff --git a/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts b/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts index 84b4209d..1c708175 100644 --- a/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts +++ b/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts @@ -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: '', } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 64b129da..4b2fb302 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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) diff --git a/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx b/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx index b54ebba7..d2ebacb8 100644 --- a/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx +++ b/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx @@ -15,6 +15,7 @@ import { Card, Col, Collapse, + Dropdown, Empty, Form, Input, @@ -25,6 +26,7 @@ import { Switch, Tag, Typography, + type MenuProps, } from 'antd' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -99,6 +101,113 @@ const DEFAULT_MATERIAL_CONFIRMATION: OrderServiceMaterialConfirmation = { cancelText: '返回检查', } +type FieldPreset = Omit + +const FIELD_PRESETS: Record = { + gameId: { + key: 'gameId', + label: '游戏 ID', + type: 'text', + validation: 'digits', + required: true, + placeholder: '请输入纯数字游戏 ID', + helpText: '仅支持数字,不会丢失前导零或超长 ID。', + options: [], + }, + gameNickname: { + key: 'gameNickname', + label: '游戏昵称', + type: 'text', + validation: 'none', + required: true, + placeholder: '请输入游戏昵称', + helpText: '用于交付时核对角色,允许中文、字母和符号。', + options: [], + }, + serverZone: { + key: 'serverZone', + label: '区服', + type: 'text', + validation: 'none', + required: true, + placeholder: '例如:微信区 / QQ区 / 具体区服', + helpText: '区服不固定的游戏可改为下拉,并填写对应选项。', + options: [], + }, + system: { + key: 'system', + label: '系统', + type: 'select', + validation: 'none', + required: false, + placeholder: '', + helpText: '按需收集客户使用的系统。', + options: ['安卓', '苹果'], + }, + gameAccount: { + key: 'gameAccount', + label: '游戏登录账号', + type: 'text', + validation: 'none', + required: false, + placeholder: '请输入游戏登录账号', + helpText: '只有确实需要登录账号时添加,不要收集密码。', + options: [], + }, + roleLevel: { + key: 'roleLevel', + label: '角色等级', + type: 'number', + validation: 'none', + required: false, + placeholder: '请输入角色等级', + helpText: '选填,用于交付前核对。', + options: [], + }, + contactPhone: { + key: 'contactPhone', + label: '联系电话', + type: 'text', + validation: 'none', + required: false, + placeholder: '请输入联系电话', + helpText: '仅在业务需要联系客户时添加。', + options: [], + }, + orderNote: { + key: 'orderNote', + label: '订单备注', + type: 'textarea', + validation: 'none', + required: false, + placeholder: '请输入补充说明', + helpText: '用于收集无法归入标准字段的补充信息。', + options: [], + }, +} + +const GAME_BASIC_PRESET_KEYS = ['gameId', 'gameNickname', 'serverZone'] +const STANDARD_FIELD_KEYS = new Set(Object.keys(FIELD_PRESETS)) +const FIELD_PRESET_MENU_ITEMS: MenuProps['items'] = [ + { key: 'game-basic', label: '游戏基础三件套(ID / 昵称 / 区服)' }, + { type: 'divider' }, + { key: 'gameId', label: '游戏 ID(默认纯数字)' }, + { key: 'gameNickname', label: '游戏昵称' }, + { key: 'serverZone', label: '区服' }, + { key: 'system', label: '系统(可选)' }, + { key: 'gameAccount', label: '游戏登录账号(可选)' }, + { key: 'roleLevel', label: '角色等级(可选)' }, + { key: 'contactPhone', label: '联系电话(可选)' }, + { key: 'orderNote', label: '订单备注(可选)' }, +] + +function buildPresetFields(keys: string[]) { + return keys.flatMap((key) => { + const preset = FIELD_PRESETS[key] + return preset ? [{ ...preset, options: [...(preset.options || [])] }] : [] + }) +} + export default function OrderServicePanel() { const { message } = App.useApp() const queryClient = useQueryClient() @@ -175,7 +284,11 @@ export default function OrderServicePanel() { autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH, materialConfirmation: nextProfile?.materialConfirmation || DEFAULT_MATERIAL_CONFIRMATION, }) - setFields(normalizeFieldOrder(nextProfile?.fields || [])) + setFields( + normalizeFieldOrder( + nextProfile ? nextProfile.fields : buildPresetFields(GAME_BASIC_PRESET_KEYS), + ), + ) setGuideImage(nextProfile?.guideImage || null) } @@ -187,15 +300,27 @@ export default function OrderServicePanel() { ]) } - function addField() { + function addFieldPreset(presetId: string) { + const presetKeys = presetId === 'game-basic' ? GAME_BASIC_PRESET_KEYS : [presetId] + const existingKeys = new Set(fields.map((field) => field.key)) + const missingKeys = presetKeys.filter((key) => FIELD_PRESETS[key] && !existingKeys.has(key)) + if (missingKeys.length === 0) { + message.info('所选字段已存在') + return + } + setFields((current) => normalizeFieldOrder([...current, ...buildPresetFields(missingKeys)])) + } + + function addCustomField() { setFields((current) => normalizeFieldOrder([ ...current, { - key: `field${Date.now().toString(36)}`, - label: '新字段', + key: '', + label: '', type: 'text', - required: true, + validation: 'none', + required: false, placeholder: '', helpText: '', options: [], @@ -207,7 +332,14 @@ export default function OrderServicePanel() { function updateField(index: number, patch: Partial) { setFields((current) => - current.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)), + current.map((row, rowIndex) => { + if (rowIndex !== index) return row + const next = { ...row, ...patch } + if (patch.key !== undefined) { + next.validation = patch.key === 'gameId' ? 'digits' : next.validation + } + return next + }), ) } @@ -235,6 +367,18 @@ export default function OrderServicePanel() { message.error(`字段 Key 不能重复:${duplicateKeys.join('、')}`) return } + const invalidKey = normalizedFields.find( + (field) => !/^[a-zA-Z][a-zA-Z0-9_]{0,59}$/.test(field.key), + ) + if (invalidKey) { + message.error('字段 Key 必须以英文字母开头,只能包含字母、数字和下划线') + return + } + const emptyLabel = normalizedFields.find((field) => !String(field.label || '').trim()) + if (emptyLabel) { + message.error('请填写所有字段的显示名称') + return + } setSaving(true) try { await saveAdminOrderServiceProfile({ @@ -577,17 +721,32 @@ export default function OrderServicePanel() {
资料字段 - 客户必须填写或选填的资料,可调整顺序。 + + 优先添加常用字段;标准字段会自动支持搜索、复制和资料匹配。 +
- + + addFieldPreset(String(key)), + }} + trigger={['click']} + > + + + +
{fields.length === 0 ? ( ) : (
@@ -597,7 +756,17 @@ export default function OrderServicePanel() {