Files
order_site/apps/backend/src/services/admin/admin-read-shared-helpers.ts
T

530 lines
16 KiB
TypeScript

import { safeParseJson } from './admin-query-utils.js'
import { normalizeAdminRole } from './admin-auth-service.js'
import { asJsonObject, type JsonObject, JsonRecord } from '../../types/json.js'
import {
parseTaskContext as parseTaskContextValue,
parseTaskState as parseTaskStateValue,
} from '../../utils/task-json.js'
import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js'
import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js'
import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js'
type CloudSourceLabelMap = Map<string, string> | Record<string, string>
type KuaishouCloudFulfillmentMapOptions = {
cloudSourceLabelMap?: CloudSourceLabelMap
}
type TaskLike = Partial<TaskRow> & {
state_json?: string | JsonRecord
}
export type AdminViewerContext = {
role: string
canViewSensitiveTaskData: boolean
canManageTaskLifecycle: boolean
canOperateAssistedTask: boolean
canOperateKuaishouIndustryVoucher: boolean
}
type RedeemResolutionAttempt = {
attempt: number
codeMasked: string
credentialType: string
outcome: string
resultCode: string
resultMessage: string
}
export function mapManualDispatchContext(
value: unknown,
viewerContext: AdminViewerContext = createAdminViewerContext(),
): JsonRecord | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
return {
outcome: String(record.outcome || '').trim(),
deliveryReference: String(record.deliveryReference || '').trim(),
deliveredCredential: viewerContext.canViewSensitiveTaskData
? String(record.deliveredCredential || '').trim()
: '',
resultMessage: String(record.resultMessage || '').trim(),
completedAt: record.completedAt || null,
completedBy:
record.completedBy && typeof record.completedBy === 'object'
? {
userId: Number(record.completedBy.userId || 0) || 0,
username: String(record.completedBy.username || '').trim(),
role: String(record.completedBy.role || '').trim(),
}
: null,
}
}
export function mapKuaishouCloudFulfillmentContext(
value: unknown,
options: KuaishouCloudFulfillmentMapOptions = {},
): JsonRecord | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
const ticket = asJsonObject(record.ticket)
const binding = asJsonObject(record.binding)
const role = asJsonObject(record.role)
const purchase = asJsonObject(record.purchase)
const dispatch = asJsonObject(record.dispatch)
const returnNumber = asJsonObject(record.returnNumber)
const consume = asJsonObject(record.consume)
const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys)
? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean)
: []
const resolvedSourceKey = String(binding.resolvedSourceKey || '').trim()
return {
flowType: String(record.flowType || '').trim(),
configId: String(record.configId || '').trim(),
internalSkuCode: String(record.internalSkuCode || '').trim(),
internalSkuName: String(record.internalSkuName || '').trim(),
ticket: {
code: String(ticket.code || '').trim(),
capturedAt: ticket.capturedAt || null,
status: String(ticket.status || 'pending').trim() || 'pending',
verifiedAt: ticket.verifiedAt || null,
goodsTitle: String(ticket.goodsTitle || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0,
},
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
cloudSourceKeys,
cloudSourceLabels: cloudSourceKeys.map((sourceKey: string) =>
resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap),
),
resolvedSourceKey,
resolvedSourceLabel: resolvedSourceKey
? resolveCloudSourceLabel(resolvedSourceKey, options.cloudSourceLabelMap)
: '',
skuId: Number(binding.skuId || 0) || 0,
skuName: String(binding.skuName || '').trim(),
vnKey: String(binding.vnKey || '').trim(),
vnId: Number(binding.vnId || 0) || 0,
vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
bindExpiresAt: binding.bindExpiresAt || null,
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName: String(binding.roleName || role.name || '').trim(),
roleId: String(binding.roleId || role.rid || '').trim(),
},
role: {
status: String(role.status || 'pending').trim() || 'pending',
name: String(role.name || binding.roleName || '').trim(),
rid: String(role.rid || binding.roleId || '').trim(),
refreshedAt: role.refreshedAt || null,
errorMessage: String(role.errorMessage || '').trim(),
rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null,
},
purchase: {
autoBuyEnabled: purchase.autoBuyEnabled !== false,
minAssetReserve: Number(purchase.minAssetReserve || 0) || 0,
usedKnapsack: purchase.usedKnapsack === true,
purchaseTriggered: purchase.purchaseTriggered === true,
assetBefore: Number(purchase.assetBefore || 0) || 0,
assetAfter: Number(purchase.assetAfter || 0) || 0,
purchaseAt: purchase.purchaseAt || null,
},
dispatch: {
status: String(dispatch.status || 'pending').trim() || 'pending',
dispatchAt: dispatch.dispatchAt || null,
sendType: Number(dispatch.sendType || 0) || 0,
note: String(dispatch.note || '').trim(),
},
returnNumber: {
status: String(returnNumber.status || 'pending').trim() || 'pending',
returnedAt: returnNumber.returnedAt || null,
autoReturnEnabled: returnNumber.autoReturnEnabled === true,
},
consume: {
status: String(consume.status || 'pending').trim() || 'pending',
shopId: String(consume.shopId || '').trim(),
shopName: String(consume.shopName || '').trim(),
autoConsumeEnabled: consume.autoConsumeEnabled === true,
consumedAt: consume.consumedAt || null,
errorMessage: String(consume.errorMessage || '').trim(),
},
rebind: mapKuaishouCloudRebindContext(record.rebind),
notes: String(record.notes || '').trim(),
}
}
export function mapKuaishouCloudTaskStateProjection(
task: TaskLike | null | undefined,
options: KuaishouCloudFulfillmentMapOptions = {},
): JsonRecord | null {
if (!task || String(task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
return null
}
const hasProjection = [
task.kuaishou_ticket_status,
task.kuaishou_ticket_code_masked,
task.kuaishou_bind_url,
task.kuaishou_vn_phone,
task.kuaishou_role_name,
task.kuaishou_role_id,
task.kuaishou_dispatch_status,
task.kuaishou_consume_status,
task.kuaishou_source_key,
].some((value) => String(value || '').trim())
if (!hasProjection) {
return null
}
const sourceKey = String(task.kuaishou_source_key || '').trim()
const roleName = String(task.kuaishou_role_name || '').trim()
const roleId = String(task.kuaishou_role_id || '').trim()
return {
flowType: 'kuaishou_cloud_fulfillment',
configId: '',
internalSkuCode: '',
internalSkuName: '',
ticket: {
code: String(task.kuaishou_ticket_code_masked || '').trim(),
capturedAt: null,
status: String(task.kuaishou_ticket_status || 'pending').trim() || 'pending',
verifiedAt: null,
goodsTitle: '',
leftCount: 0,
},
binding: {
prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending',
cloudSourceKeys: sourceKey ? [sourceKey] : [],
cloudSourceLabels: sourceKey
? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)]
: [],
resolvedSourceKey: sourceKey,
resolvedSourceLabel: sourceKey
? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)
: '',
skuId: 0,
skuName: '',
vnKey: '',
vnId: 0,
vnPhone: String(task.kuaishou_vn_phone || '').trim(),
bindUrl: String(task.kuaishou_bind_url || '').trim(),
bindPreparedAt: null,
bindExpiresAt: null,
bindProbeAt: null,
bindProbeStatus: '',
bindProbeMessage: '',
},
role: {
status: roleName || roleId ? 'ready' : 'pending',
name: roleName,
rid: roleId,
refreshedAt: null,
errorMessage: '',
rawInfo: null,
},
purchase: {
autoBuyEnabled: true,
minAssetReserve: 0,
usedKnapsack: false,
purchaseTriggered: false,
assetBefore: 0,
assetAfter: 0,
purchaseAt: null,
},
dispatch: {
status: String(task.kuaishou_dispatch_status || 'pending').trim() || 'pending',
dispatchAt: null,
sendType: 0,
note: '',
},
returnNumber: {
status: 'pending',
returnedAt: null,
autoReturnEnabled: false,
},
consume: {
status: String(task.kuaishou_consume_status || 'pending').trim() || 'pending',
shopId: '',
shopName: '',
autoConsumeEnabled: false,
consumedAt: null,
errorMessage: '',
},
rebind: {
currentAttempt: 0,
history: [],
},
notes: '',
}
}
function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMap | undefined) {
const key = String(sourceKey || '').trim()
if (!key) return ''
const label = labelMap instanceof Map ? labelMap.get(key) : labelMap?.[key]
return String(label || key).trim() || key
}
function mapKuaishouCloudRebindContext(value: unknown): JsonRecord {
const record = value && typeof value === 'object' ? (value as JsonRecord) : {}
const history = Array.isArray(record.history) ? record.history : []
return {
currentAttempt: Math.max(0, Number(record.currentAttempt || 0) || 0),
history: history.map((item) => mapKuaishouCloudRebindHistoryItem(item)).filter(Boolean),
}
}
function mapKuaishouCloudRebindHistoryItem(value: unknown): JsonRecord | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
const oldBinding =
record.oldBinding && typeof record.oldBinding === 'object'
? (record.oldBinding as JsonRecord)
: {}
const newBinding =
record.newBinding && typeof record.newBinding === 'object'
? (record.newBinding as JsonRecord)
: null
return {
attempt: Math.max(1, Number(record.attempt || 1) || 1),
source: String(record.source || '').trim(),
requestedAt: record.requestedAt || null,
requestedBy:
record.requestedBy && typeof record.requestedBy === 'object' ? record.requestedBy : null,
status: String(record.status || '').trim(),
errorMessage: String(record.errorMessage || '').trim(),
oldBinding: {
vnKey: String(oldBinding.vnKey || '').trim(),
vnId: Number(oldBinding.vnId || 0) || 0,
vnPhone: String(oldBinding.vnPhone || '').trim(),
bindUrl: String(oldBinding.bindUrl || '').trim(),
bindPreparedAt: oldBinding.bindPreparedAt || null,
bindExpiresAt: oldBinding.bindExpiresAt || null,
roleName: String(oldBinding.roleName || '').trim(),
roleId: String(oldBinding.roleId || '').trim(),
},
newBinding: newBinding
? {
vnKey: String(newBinding.vnKey || '').trim(),
vnId: Number(newBinding.vnId || 0) || 0,
vnPhone: String(newBinding.vnPhone || '').trim(),
bindUrl: String(newBinding.bindUrl || '').trim(),
bindPreparedAt: newBinding.bindPreparedAt || null,
bindExpiresAt: newBinding.bindExpiresAt || null,
}
: null,
}
}
export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
const attempts = Array.isArray(record.attempts)
? record.attempts
.map((attempt) => mapRedeemResolutionAttempt(attempt))
.filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt))
: []
return {
status: String(record.status || '').trim(),
taskStatus: String(record.taskStatus || '').trim(),
replacementCount: Math.max(0, Number(record.replacementCount || 0)),
finishedAt: record.finishedAt || null,
attempts,
}
}
export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null {
const value = Number(task?.primary_claim_token_id || 0)
return Number.isFinite(value) && value > 0 ? value : null
}
export function createAdminViewerContext(
session: AdminViewerSessionInput | null = null,
): AdminViewerContext {
const role = normalizeAdminRole(session?.role)
return {
role,
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
canManageTaskLifecycle: role === 'admin' || role === 'operator',
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
canOperateKuaishouIndustryVoucher:
role === 'admin' || role === 'operator' || role === 'support',
}
}
export async function resolveAdminTaskScreenshotUrl(
task: TaskLike,
viewerContext: AdminViewerContext,
): Promise<string> {
if (task.screenshot_path || task.runtime_session_id) {
return `/api/v1/admin/tasks/${task.id}/screenshot`
}
return ''
}
export function canViewerCloseTask(task: TaskLike, viewerContext: AdminViewerContext): boolean {
if (!['admin', 'operator', 'support'].includes(viewerContext.role)) {
return false
}
return !isTaskFinalStatus(task?.task_status)
}
export function resolveDisplayShopName(
provider: unknown,
shopId: unknown,
shopName: unknown,
): string {
const normalizedShopName = String(shopName || '').trim()
if (normalizedShopName) {
return normalizedShopName
}
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return ''
}
return normalizedShopId
}
export function parseTaskContext(task: TaskLike | null | undefined): JsonRecord {
return parseTaskContextValue(task)
}
export function parseTaskState(task: TaskLike | null | undefined): JsonRecord {
return parseTaskStateValue(task)
}
export function resolveOrderItemTitle(item: Partial<OrderItemRow> | null | undefined): string {
if (!item) {
return ''
}
const spec = safeParseJson(item.spec_json)
return pickFirstNonEmpty([
spec.title,
spec.Title,
spec.itemTitle,
spec.item_title,
spec.goods_name,
spec.goodsName,
item.sku_name,
item.sku_code,
])
}
export function resolveOrderItemDeliveryMode(
tasks: TaskLike[] | null | undefined,
orderItemId: number | string,
): string {
const task = (Array.isArray(tasks) ? tasks : []).find(
(item) => item.order_item_id === orderItemId,
)
if (!task) {
return ''
}
if (String(task.executor_key || '').trim() === 'manual_dispatch') {
return 'manual_dispatch'
}
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') {
return 'kuaishou_cloud'
}
if (
task.requires_claim ||
getTaskPrimaryClaimTokenId(task) ||
task.primary_claim_token ||
task.claim_token
) {
return 'claim_link'
}
return String(task.executor_key || '').trim()
}
export function isManualDispatchTask(task: TaskLike | null | undefined): boolean {
return String(task?.executor_key || '').trim() === 'manual_dispatch'
}
export function canRegenerateClaimLinkForViewer(
task: TaskLike,
viewerContext: AdminViewerContext,
): boolean {
if (isManualDispatchTask(task)) {
return false
}
const baseAllowed = canRegenerateClaimLinkStatus(task.task_status)
if (!baseAllowed) {
return false
}
if (viewerContext.canManageTaskLifecycle) {
return true
}
return false
}
function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
return {
attempt: Math.max(1, Number(record.attempt || 1)),
codeMasked: String(record.codeMasked || '').trim(),
credentialType: String(record.credentialType || '').trim(),
outcome: String(record.outcome || '').trim(),
resultCode: String(record.resultCode || '').trim(),
resultMessage: String(record.resultMessage || '').trim(),
}
}
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
}
return ''
}