deduplicate task parsing and masking helpers

This commit is contained in:
yml
2026-05-21 18:50:07 +08:00
parent efa41ba976
commit 17a615b8de
9 changed files with 151 additions and 491 deletions
@@ -1,6 +1,10 @@
import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js'
import { safeParseJson } from './admin-query-utils.js'
import { normalizeAdminRole } from './admin-auth-service.js'
import {
parseTaskContext as parseTaskContextValue,
parseTaskState as parseTaskStateValue,
} from '../../utils/task-json.js'
import type { AdminViewerSessionInput } from '../../types/admin-read-inputs.js'
import type { OrderItemRow, TaskRow } from '../../types/repository-rows.js'
@@ -227,39 +231,11 @@ export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopN
}
export function parseTaskContext(task: TaskLike | null | undefined): JsonRecord {
const value = task?.context_json
if (!value) {
return {}
}
if (typeof value === 'object') {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
return parseTaskContextValue(task)
}
export function parseTaskState(task: TaskLike | null | undefined): JsonRecord {
const value = task?.state_json
if (!value) {
return {}
}
if (typeof value === 'object') {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
return parseTaskStateValue(task)
}
export function resolveOrderItemTitle(item: Partial<OrderItemRow> | null | undefined): string {
@@ -1,29 +1,16 @@
import {
maskPhone as maskPhoneValue,
maskSecret as maskSecretValue,
} from "../../../utils/masking.js";
type JsonObject = Record<string, any>
export function maskSecret(value: unknown) {
const normalized = String(value || "").trim();
if (!normalized) {
return "";
}
if (normalized.length <= 10) {
return `${normalized.slice(0, 2)}****${normalized.slice(-2)}`;
}
return `${normalized.slice(0, 6)}****${normalized.slice(-6)}`;
return maskSecretValue(value);
}
export function maskPhone(value: unknown) {
const normalized = String(value || "").trim();
if (!normalized) {
return "";
}
if (normalized.length < 7) {
return normalized;
}
return `${normalized.slice(0, 3)}****${normalized.slice(-4)}`;
return maskPhoneValue(value, { maskShort: false });
}
export function mapAdminKuaishouEticketShopItem(item: JsonObject = {}) {
@@ -1,20 +1,22 @@
import { normalizeProductName } from '../../order/product-match-service.js'
import { getCloudtentaclesSourceConfig } from '../../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionState } from '../../platforms/cloudtentacles/session-state-service.js'
import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/shared.js'
import {
appointCloudtentaclesVirtualNumber,
backCloudtentaclesVirtualNumber,
fetchCloudtentaclesVirtualNumberCode,
generateCloudtentaclesLoginCode,
getCloudtentaclesBindUrl,
verifyCloudtentaclesLoginCode,
} from '../../platforms/cloudtentacles/virtual-number-service.js'
import { createHttpError } from '../../../utils/http.js'
import type { HttpErrorLike } from '../../../utils/http.js'
import {
resolvePersistedCloudtentaclesContext as resolveFulfillmentPersistedCloudtentaclesContext,
} from '../../fulfillment/kuaishou-cloud/cloudtentacles-context.js'
export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1'
export type JsonObject = Record<string, any>
export {
KUAISHOU_CLOUD_FIXED_VN_KEY,
isKuaishouCloudTask,
normalizeKuaishouCloudFlow,
normalizeKuaishouCloudRoleInfo,
resolveKuaishouCloudBindUrlExpiresAt,
type JsonObject,
} from '../../fulfillment/kuaishou-cloud/domain.js'
export {
prepareKuaishouCloudBindResourceWithFallback,
resolveKuaishouCloudBindingResources,
resolveKuaishouCloudVnKeyCandidates,
} from '../../fulfillment/kuaishou-cloud/binding-resources.js'
export type CloudtentaclesContext = {
baseUrl: string
@@ -31,17 +33,17 @@ export type CloudSkuLikeItem = {
[key: string]: unknown
}
export type KuaishouCloudFlow = JsonObject & {
export type KuaishouCloudFlow = Record<string, any> & {
configId: string
internalSkuCode: string
internalSkuName: string
ticket: JsonObject
binding: JsonObject
role: JsonObject
purchase: JsonObject
dispatch: JsonObject
returnNumber: JsonObject
consume: JsonObject
ticket: Record<string, any>
binding: Record<string, any>
role: Record<string, any>
purchase: Record<string, any>
dispatch: Record<string, any>
returnNumber: Record<string, any>
consume: Record<string, any>
}
export type KuaishouCloudBindingResources = {
@@ -60,332 +62,23 @@ export type PreparedKuaishouCloudBindResource = {
bindUrl: string
}
export function isKuaishouCloudTask(task: unknown): boolean {
const record: JsonObject = task && typeof task === 'object' ? task : {}
return String(record.executor_key || '').trim() === 'kuaishou_ct_assisted'
}
export function resolvePersistedCloudtentaclesContext(): CloudtentaclesContext {
const source = getCloudtentaclesSourceConfig()
const session = getCloudtentaclesSessionState()
const token = String(session.token || '').trim()
if (!token) {
throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_cloud_token',
})
}
return {
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
token,
deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-',
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
}
}
export function normalizeKuaishouCloudFlow(value: unknown): KuaishouCloudFlow {
const source: JsonObject = value && typeof value === 'object' ? value : {}
const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
const role = source.role && typeof source.role === 'object' ? source.role : {}
const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {}
const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {}
const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {}
const consume = source.consume && typeof source.consume === 'object' ? source.consume : {}
const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {}
const bindPreparedAt = binding.bindPreparedAt || null
return {
...source,
configId: String(source.configId || '').trim(),
internalSkuCode: String(source.internalSkuCode || '').trim(),
internalSkuName: String(source.internalSkuName || '').trim(),
ticket: {
code: String(ticket.code || '').trim(),
status: String(ticket.status || 'pending').trim() || 'pending',
capturedAt: ticket.capturedAt || null,
capturedBy: ticket.capturedBy || null,
verifiedAt: ticket.verifiedAt || null,
oid: String(ticket.oid || '').trim(),
formToken: String(ticket.formToken || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0,
goodsTitle: String(ticket.goodsTitle || '').trim(),
},
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
cloudSourceKey: String(binding.cloudSourceKey || 'default').trim() || 'default',
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,
bindExpiresAt: binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt),
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName: String(binding.roleName || '').trim(),
roleId: String(binding.roleId || '').trim(),
},
role: {
status: String(role.status || 'pending').trim() || 'pending',
name: String(role.name || '').trim(),
rid: String(role.rid || '').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,
dispatchBy: dispatch.dispatchBy || null,
sendType: Number(dispatch.sendType || 0) || 0,
note: String(dispatch.note || '').trim(),
},
returnNumber: {
status: String(returnNumber.status || 'pending').trim() || 'pending',
returnedAt: returnNumber.returnedAt || null,
returnedBy: returnNumber.returnedBy || 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(),
},
}
}
export function normalizeKuaishouCloudRoleInfo(value: unknown): { name: string, rid: string, rawInfo: JsonObject | null } {
const rawInfo: JsonObject | null = value && typeof value === 'object' ? value : null
const nestedBindInfo = rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null
const source = nestedBindInfo || rawInfo
return {
name: String(source?.name || source?.roleName || source?.nickname || source?.sRoleName || '').trim(),
rid: String(source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '').trim(),
rawInfo,
}
}
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown): string | null {
const preparedTime = Date.parse(String(preparedAt || ''))
if (!Number.isFinite(preparedTime)) {
return null
}
const config = resolveCloudtentaclesConfig()
const ttlSeconds = Number(config.bindUrlTtlSeconds || 600)
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString()
}
export function resolveKuaishouCloudBindingResources(
flow: KuaishouCloudFlow,
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {},
): KuaishouCloudBindingResources {
const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : []
const normalizedKnapsackItems = Array.isArray(knapsackItems) ? knapsackItems.filter(isCloudSkuLikeItem) : []
const currentSkuId = Number(flow?.binding?.skuId || 0) || 0
const currentSkuName = String(flow?.binding?.skuName || '').trim()
const nameCandidates = collectKuaishouCloudNameCandidates(flow)
const skuItemById = currentSkuId > 0
? normalizedSkuItems.find((item) => Number(item.id || 0) === currentSkuId) || null
: null
const knapsackItemById = currentSkuId > 0
? normalizedKnapsackItems.find((item) => Number(item.id || 0) === currentSkuId) || null
: null
if (skuItemById || knapsackItemById) {
const matchedItem = skuItemById || knapsackItemById
return {
skuId: Number(matchedItem?.id || 0) || 0,
skuName: String(currentSkuName || matchedItem?.name || '').trim(),
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
skuItem: skuItemById,
knapsackItem: knapsackItemById,
resolvedByName: false,
try {
return resolveFulfillmentPersistedCloudtentaclesContext()
} catch (error) {
if (isMissingCloudtentaclesTokenError(error)) {
throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_cloud_token',
cause: error,
})
}
}
const matchedSkuItem = findCloudItemByNames(normalizedSkuItems, nameCandidates)
const matchedKnapsackItem = findCloudItemByNames(
normalizedKnapsackItems,
nameCandidates,
matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0,
)
const matchedItem = matchedSkuItem || matchedKnapsackItem
return {
skuId: Number(matchedItem?.id || 0) || 0,
skuName: String(currentSkuName || matchedItem?.name || '').trim(),
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
skuItem: matchedSkuItem,
knapsackItem: matchedKnapsackItem,
resolvedByName: Boolean(matchedItem),
throw error
}
}
export function resolveKuaishouCloudVnKeyCandidates(input: { flow?: KuaishouCloudFlow, binding?: KuaishouCloudBindingResources } = {}): string[] {
return [KUAISHOU_CLOUD_FIXED_VN_KEY]
}
export async function prepareKuaishouCloudBindResourceWithFallback(input: {
cloudContext?: CloudtentaclesContext
vnKeyCandidates?: string[]
} = {}): Promise<PreparedKuaishouCloudBindResource> {
const { cloudContext = {}, vnKeyCandidates = [] } = input
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : []
let lastError: unknown = null
for (const vnKey of candidates) {
let vnId = 0
let vnPhone = ''
try {
const appointed = await appointCloudtentaclesVirtualNumber({
...cloudContext,
key: vnKey,
})
vnId = Number(appointed.item?.id || 0)
vnPhone = String(appointed.item?.phone || '').trim()
if (!vnId || !vnPhone) {
throw createHttpError('申请虚拟号成功但返回数据不完整', {
statusCode: 502,
errorCode: 'admin_task_kuaishou_cloud_invalid_vn',
})
}
await generateCloudtentaclesLoginCode({
...cloudContext,
key: vnKey,
id: vnId,
})
const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({
...cloudContext,
key: vnKey,
phone: vnPhone,
})
await verifyCloudtentaclesLoginCode({
...cloudContext,
key: vnKey,
id: vnId,
code: fetchedCode.code,
})
const bindUrlResult = await getCloudtentaclesBindUrl({
...cloudContext,
key: vnKey,
id: vnId,
})
return {
vnKey,
vnId,
vnPhone,
bindUrl: String(bindUrlResult.bindUrl || '').trim(),
}
} catch (error) {
lastError = error
if (vnId > 0) {
try {
await backCloudtentaclesVirtualNumber({
...cloudContext,
key: vnKey,
id: vnId,
})
} catch {
// 兜底退号失败时保留原始错误,避免吞掉主链路异常
}
}
if (!isRecoverableKuaishouCloudVnKeyError(error)) {
throw error
}
}
}
throw lastError || createHttpError('没有找到可用的 VN Key', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_binding_config',
})
}
function collectKuaishouCloudNameCandidates(flow: KuaishouCloudFlow): string[] {
return Array.from(new Set([
String(flow?.binding?.skuName || '').trim(),
String(flow?.internalSkuName || '').trim(),
String(flow?.internalSkuCode || '').trim(),
].filter(Boolean)))
}
function findCloudItemByNames(items: CloudSkuLikeItem[], nameCandidates: string[], preferredId = 0): CloudSkuLikeItem | null {
const normalizedItems = Array.isArray(items) ? items : []
const normalizedNames = nameCandidates
.map((item) => ({
raw: String(item || '').trim(),
normalized: normalizeProductName(item),
}))
.filter((item) => item.raw && item.normalized)
if (normalizedNames.length === 0 || normalizedItems.length === 0) {
return preferredId > 0
? normalizedItems.find((item) => Number(item.id || 0) === preferredId) || null
: null
}
if (preferredId > 0) {
const preferred = normalizedItems.find((item) => Number(item.id || 0) === preferredId) || null
if (preferred) {
return preferred
}
}
const exactMatches = normalizedItems.filter((item) => {
const itemName = normalizeProductName(item.name)
return normalizedNames.some((candidate) => candidate.normalized === itemName)
})
if (exactMatches.length > 0) {
return exactMatches[0]
}
const partialMatches = normalizedItems.filter((item) => {
const itemName = normalizeProductName(item.name)
return normalizedNames.some((candidate) => itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName))
})
if (partialMatches.length > 0) {
return partialMatches.sort((left, right) => String(left.name || '').length - String(right.name || '').length)[0]
}
return null
}
function isCloudSkuLikeItem(item: unknown): item is CloudSkuLikeItem {
const record: JsonObject = item && typeof item === 'object' ? item : {}
return Number(record.id || 0) > 0
}
function isRecoverableKuaishouCloudVnKeyError(error: any): boolean {
const errorCode = String(error?.errorCode || error?.code || '').trim()
const errorMessage = String(error?.message || '').trim()
return errorCode === 'cloudtentacles_vn_bind_url_failed'
&& errorMessage.includes('不支持的游戏类型')
function isMissingCloudtentaclesTokenError(error: unknown): boolean {
const current = error as HttpErrorLike | null
return String(current?.errorCode || '').trim() === 'kuaishou_cloud_missing_cloud_token'
}
@@ -1,5 +1,9 @@
import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
import { createHttpError } from '../../../utils/http.js'
import {
maskCode as maskCodeValue,
maskPhone as maskPhoneValue,
} from '../../../utils/masking.js'
import {
canViewerConfirmAssistedRole,
canViewerRedeemAssistedTask,
@@ -24,29 +28,11 @@ type ErrorLike = {
}
export function maskPhone(value: unknown): string {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 7) {
return `${text.slice(0, 2)}***${text.slice(-2)}`
}
return `${text.slice(0, 3)}****${text.slice(-4)}`
return maskPhoneValue(value)
}
export function maskCode(value: unknown): string {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 8) {
return `${text.slice(0, 2)}***${text.slice(-2)}`
}
return `${text.slice(0, 4)}****${text.slice(-4)}`
return maskCodeValue(value)
}
export function resolveTaskInventoryGroupCodes(task: TaskRow): string[] | null {
@@ -1,6 +1,11 @@
import { buildClaimUrl } from '../claim-service.js'
import { createHttpError } from '../../../utils/http.js'
import { maskCode as maskCodeValue } from '../../../utils/masking.js'
import { formatFenToAmount, normalizeFen } from '../../../utils/money.js'
import {
parseTaskContext as parseTaskContextValue,
parseTaskState as parseTaskStateValue,
} from '../../../utils/task-json.js'
export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
export const REDEEM_REPLACEMENT_LIMIT = 10
@@ -178,21 +183,7 @@ export function mergeTaskContext(task, patch = {}) {
}
export function parseTaskState(task) {
const value = task?.state_json
if (!value) {
return {}
}
if (typeof value === 'object') {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
return parseTaskStateValue(task)
}
export function isAssistedClaimTask(task) {
@@ -200,21 +191,7 @@ export function isAssistedClaimTask(task) {
}
export function parseTaskContext(task) {
const value = task?.context_json
if (!value) {
return {}
}
if (typeof value === 'object') {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
return parseTaskContextValue(task)
}
export function normalizeClaimLoginType(loginType) {
@@ -227,15 +204,5 @@ export function isRecoverableSessionError(error) {
}
export function maskCode(value) {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 8) {
return `${text.slice(0, 2)}****${text.slice(-2)}`
}
return `${text.slice(0, 4)}****${text.slice(-4)}`
return maskCodeValue(value, { shortMask: '****' })
}
@@ -1,4 +1,8 @@
import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/shared.js";
import {
maskCode as maskCodeValue,
maskPhone as maskPhoneValue,
} from "../../../utils/masking.js";
export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1";
@@ -154,29 +158,11 @@ export function normalizeKuaishouCloudRoleInfo(value) {
}
export function maskPhone(value) {
const text = String(value || "").trim();
if (!text) {
return "";
}
if (text.length <= 7) {
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
return `${text.slice(0, 3)}****${text.slice(-4)}`;
return maskPhoneValue(value);
}
export function maskCode(value) {
const text = String(value || "").trim();
if (!text) {
return "";
}
if (text.length <= 8) {
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
return `${text.slice(0, 4)}****${text.slice(-4)}`;
return maskCodeValue(value);
}
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) {
@@ -1,3 +1,5 @@
import { parseTaskContext as parseTaskContextValue } from "../../../utils/task-json.js";
export function normalizeActor(actor) {
if (!actor || typeof actor !== "object") {
return null;
@@ -21,21 +23,7 @@ export function normalizeActor(actor) {
}
export function parseTaskContext(task) {
const value = task?.context_json;
if (!value) {
return {};
}
if (typeof value === "object") {
return value;
}
try {
return JSON.parse(String(value || "{}"));
} catch {
return {};
}
return parseTaskContextValue(task);
}
export function getTaskClaimExpiresAt(task) {