diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js
index 8440306d..a0e80c04 100644
--- a/apps/backend/src/routes/admin.js
+++ b/apps/backend/src/routes/admin.js
@@ -4,6 +4,7 @@ import authRouter from './admin/auth.js'
import auditLogsRouter from './admin/audit-logs.js'
import dashboardRouter from './admin/dashboard.js'
import inventoryRouter from './admin/inventory.js'
+import manualRedeemRouter from './admin/manual-redeem.js'
import messageDeliveriesRouter from './admin/message-deliveries.js'
import ordersRouter from './admin/orders.js'
import platformConfigRouter from './admin/platform-config.js'
@@ -23,6 +24,7 @@ router.use(auditLogsRouter)
router.use(platformConfigRouter)
router.use(ordersRouter)
router.use(tasksRouter)
+router.use(manualRedeemRouter)
router.use(inventoryRouter)
router.use(messageDeliveriesRouter)
router.use(webhookEventsRouter)
diff --git a/apps/backend/src/routes/admin/manual-redeem.js b/apps/backend/src/routes/admin/manual-redeem.js
new file mode 100644
index 00000000..659ac736
--- /dev/null
+++ b/apps/backend/src/routes/admin/manual-redeem.js
@@ -0,0 +1,183 @@
+// @ts-check
+
+import { Router } from 'express'
+
+import {
+ closeAdminManualRedeemSession,
+ closeAdminManualRedeemTask,
+ confirmAdminManualRedeemRole,
+ createAdminManualRedeemSession,
+ createAdminManualRedeemTask,
+ getAdminManualRedeemDetail,
+ getAdminManualRedeemSessionSummary,
+ reloadAdminManualRedeemSession,
+ redeemAdminManualRedeemTask,
+} from '../../services/admin/admin-manual-redeem-service.js'
+import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js'
+import { createJsonHandler, requireAdminRoles } from './shared.js'
+
+/** @typedef {import('../../types/admin-route-inputs.js').AdminInventorySkuSuggestionRouteQuery} AdminInventorySkuSuggestionRouteQuery */
+/** @typedef {import('../../types/admin-route-inputs.js').AdminManualRedeemCreateRouteBody} AdminManualRedeemCreateRouteBody */
+/** @typedef {import('../../types/admin-route-inputs.js').AdminManualRedeemRouteParams} AdminManualRedeemRouteParams */
+
+const router = Router()
+
+router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support']))
+
+router.get('/manual-redeem/sku-suggestions', createJsonHandler(
+ (req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)),
+ {
+ successMessage: 'ok',
+ errorMessage: '读取人工兑换 SKU 建议失败',
+ scope: '[admin/manual-redeem/sku-suggestions]',
+ },
+))
+
+router.post('/manual-redeem', createJsonHandler(
+ (req) => createAdminManualRedeemTask(
+ /** @type {AdminManualRedeemCreateRouteBody} */ (req.body),
+ req.adminSession || null,
+ ),
+ {
+ successMessage: '人工兑换任务已创建',
+ errorMessage: '创建人工兑换任务失败',
+ scope: '[admin/manual-redeem]',
+ audit: (req, data) => {
+ const result = /** @type {{ task?: { taskId?: number, taskNo?: string }, manualRequest?: { proofValue?: string }, order?: { platformOrderId?: string }, orderItem?: { skuCode?: string } }} */ (data)
+ return {
+ action: 'manual_redeem_created',
+ targetType: 'task',
+ targetId: String(result.task?.taskId || ''),
+ data: {
+ taskId: result.task?.taskId,
+ taskNo: result.task?.taskNo,
+ proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '',
+ skuCode: result.orderItem?.skuCode || '',
+ },
+ }
+ },
+ },
+))
+
+router.get('/manual-redeem/:taskId', createJsonHandler(
+ (req) => getAdminManualRedeemDetail((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
+ {
+ successMessage: 'ok',
+ errorMessage: '读取人工兑换详情失败',
+ scope: '[admin/manual-redeem/:taskId]',
+ },
+))
+
+router.post('/manual-redeem/:taskId/session', createJsonHandler(
+ (req) => createAdminManualRedeemSession(
+ (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
+ /** @type {{ loginType?: string, forceRecreate?: boolean }} */ (req.body),
+ ),
+ {
+ successMessage: '人工兑换登录会话已创建',
+ errorMessage: '创建人工兑换登录会话失败',
+ scope: '[admin/manual-redeem/:taskId/session]',
+ },
+))
+
+router.get('/manual-redeem/:taskId/session/summary', createJsonHandler(
+ (req) => getAdminManualRedeemSessionSummary((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
+ {
+ successMessage: 'ok',
+ errorMessage: '读取人工兑换会话摘要失败',
+ scope: '[admin/manual-redeem/:taskId/session/summary]',
+ },
+))
+
+router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler(
+ (req) => reloadAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
+ {
+ successMessage: '人工兑换会话已刷新',
+ errorMessage: '刷新人工兑换会话失败',
+ scope: '[admin/manual-redeem/:taskId/session/refresh]',
+ },
+))
+
+router.delete('/manual-redeem/:taskId/session', createJsonHandler(
+ (req) => closeAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
+ {
+ successMessage: '人工兑换会话已关闭',
+ errorMessage: '关闭人工兑换会话失败',
+ scope: '[admin/manual-redeem/:taskId/session]',
+ },
+))
+
+router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler(
+ (req) => confirmAdminManualRedeemRole(
+ (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
+ req.adminSession || null,
+ ),
+ {
+ successMessage: '角色已确认',
+ errorMessage: '确认人工兑换角色失败',
+ scope: '[admin/manual-redeem/:taskId/confirm-role]',
+ audit: (req, data) => {
+ const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string } }} */ (data)
+ return {
+ action: 'manual_redeem_role_confirmed',
+ targetType: 'task',
+ targetId: String(result.task?.taskId || ''),
+ data: {
+ taskId: result.task?.taskId,
+ taskNo: result.task?.taskNo,
+ status: result.task?.status,
+ },
+ }
+ },
+ },
+))
+
+router.post('/manual-redeem/:taskId/redeem', createJsonHandler(
+ (req) => redeemAdminManualRedeemTask(
+ (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
+ req.adminSession || null,
+ ),
+ {
+ successMessage: '人工兑换任务已启动',
+ errorMessage: '执行人工兑换失败',
+ scope: '[admin/manual-redeem/:taskId/redeem]',
+ audit: (req, data) => {
+ const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string, deliveryStatus?: string } }} */ (data)
+ return {
+ action: 'manual_redeem_started',
+ targetType: 'task',
+ targetId: String(result.task?.taskId || ''),
+ data: {
+ taskId: result.task?.taskId,
+ taskNo: result.task?.taskNo,
+ status: result.task?.status,
+ deliveryStatus: result.task?.deliveryStatus,
+ },
+ }
+ },
+ },
+))
+
+router.post('/manual-redeem/:taskId/close', createJsonHandler(
+ (req) => closeAdminManualRedeemTask((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
+ {
+ successMessage: '人工兑换任务已关闭',
+ errorMessage: '关闭人工兑换任务失败',
+ scope: '[admin/manual-redeem/:taskId/close]',
+ audit: (req, data) => {
+ const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string } }} */ (data)
+ return {
+ action: 'manual_redeem_closed',
+ targetType: 'task',
+ targetId: String(result.task?.taskId || ''),
+ data: {
+ taskId: result.task?.taskId,
+ taskNo: result.task?.taskNo,
+ status: result.task?.status,
+ },
+ }
+ },
+ },
+))
+
+export default router
diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.js b/apps/backend/src/services/admin/admin-manual-redeem-service.js
new file mode 100644
index 00000000..87b428ce
--- /dev/null
+++ b/apps/backend/src/services/admin/admin-manual-redeem-service.js
@@ -0,0 +1,376 @@
+// @ts-check
+
+import { createTaskClaimToken } from '../claim/claim-service.js'
+import {
+ closeClaimSessionForAdminTask,
+ createClaimSessionForAdminTask,
+ getClaimDetailForAdminTask,
+ getClaimSessionSummaryForAdminTask,
+ reloadClaimSessionForAdminTask,
+} from '../claim/claim-session-service.js'
+import { reserveInventoryForTask } from '../order/inventory-service.js'
+import { createHttpError } from '../../utils/http.js'
+import { replaceOrderItems } from '../../repositories/order-item-repo.js'
+import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
+import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
+import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
+import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
+import { createTaskEvent } from '../../repositories/task-event-repo.js'
+import { nowIso } from '../../utils/time.js'
+import { randomId } from '../../utils/random.js'
+import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
+import { isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
+
+const MANUAL_PROVIDER = 'manual'
+const MANUAL_PLATFORM = 'manual_redeem'
+const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
+const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
+
+/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */
+/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */
+/** @typedef {import('../../types/admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateInput */
+
+/** @param {AdminManualRedeemCreateInput} [payload] */
+/** @param {AdminViewerSessionInput | null} [session] */
+export async function createAdminManualRedeemTask(
+ payload = /** @type {AdminManualRedeemCreateInput} */ ({}),
+ session = null,
+) {
+ const proofValue = normalizeManualProofValue(payload.proofValue)
+ const skuCode = String(payload.skuCode || '').trim()
+ const skuName = String(payload.skuName || '').trim() || skuCode
+ const remark = String(payload.remark || '').trim()
+
+ if (!proofValue) {
+ throw createHttpError('请先输入唯一凭据', {
+ statusCode: 400,
+ errorCode: 'admin_manual_redeem_missing_proof',
+ })
+ }
+
+ if (!skuCode) {
+ throw createHttpError('请先选择内部履约 SKU', {
+ statusCode: 400,
+ errorCode: 'admin_manual_redeem_missing_sku',
+ })
+ }
+
+ const existingOrder = await findOrderByPlatformOrderId({
+ provider: MANUAL_PROVIDER,
+ platform: MANUAL_PLATFORM,
+ shopId: '',
+ platformOrderId: proofValue,
+ })
+
+ if (existingOrder) {
+ throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
+ statusCode: 409,
+ errorCode: 'admin_manual_redeem_duplicate_proof',
+ })
+ }
+
+ const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
+ if (!profile) {
+ throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
+ statusCode: 500,
+ errorCode: 'admin_manual_redeem_profile_missing',
+ })
+ }
+
+ const requirements = await listFulfillmentProfileRequirements(profile.id)
+ const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
+ const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
+ const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
+ const availableInventory = await findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType)
+
+ if (!availableInventory) {
+ throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
+ statusCode: 409,
+ errorCode: 'admin_manual_redeem_inventory_unavailable',
+ })
+ }
+
+ const now = nowIso()
+ const manualContext = {
+ sourceType: MANUAL_SOURCE_TYPE,
+ proofValue,
+ remark,
+ createdAt: now,
+ createdBy: session
+ ? {
+ userId: Number(session.userId || 0) || 0,
+ username: String(session.username || '').trim(),
+ role: String(session.role || '').trim(),
+ }
+ : null,
+ }
+
+ let order
+ try {
+ order = await createOrder({
+ provider: MANUAL_PROVIDER,
+ platform: MANUAL_PLATFORM,
+ shopId: '',
+ shopName: '人工兑换',
+ platformOrderId: proofValue,
+ orderStatus: 'manual_created',
+ payStatus: 'paid',
+ buyerId: '',
+ buyerName: '',
+ receiverContact: '',
+ totalAmount: 0,
+ currency: 'CNY',
+ rawPayloadJson: JSON.stringify({
+ manualRedeem: manualContext,
+ }),
+ paidAt: now,
+ createdAt: now,
+ updatedAt: now,
+ })
+ } catch (error) {
+ if (String(error?.code || '') === '23505') {
+ throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
+ statusCode: 409,
+ errorCode: 'admin_manual_redeem_duplicate_proof',
+ })
+ }
+
+ throw error
+ }
+
+ if (!order) {
+ throw createHttpError('人工兑换订单创建失败', {
+ statusCode: 500,
+ errorCode: 'admin_manual_redeem_order_create_failed',
+ })
+ }
+
+ const orderItems = await replaceOrderItems(order.id, [
+ {
+ skuCode,
+ skuName,
+ quantity: 1,
+ specJson: JSON.stringify({
+ title: skuName,
+ sourceType: MANUAL_SOURCE_TYPE,
+ }),
+ itemSnapshotJson: JSON.stringify({
+ manualRedeem: manualContext,
+ skuCode,
+ skuName,
+ }),
+ createdAt: now,
+ updatedAt: now,
+ },
+ ])
+
+ const orderItem = orderItems[0]
+ if (!orderItem) {
+ throw createHttpError('人工兑换订单商品创建失败', {
+ statusCode: 500,
+ errorCode: 'admin_manual_redeem_order_item_create_failed',
+ })
+ }
+
+ const createdTask = await createTask({
+ orderId: order.id,
+ orderItemId: orderItem.id,
+ unitIndex: 1,
+ provider: MANUAL_PROVIDER,
+ platform: MANUAL_PLATFORM,
+ shopId: '',
+ shopName: '人工兑换',
+ platformOrderId: proofValue,
+ taskNo: randomId('MR'),
+ profileId: Number(profile.id),
+ executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
+ taskStatus: 'paid',
+ inventoryStatus: 'pending',
+ deliveryStatus: 'pending',
+ resultCode: '',
+ resultMessage: '',
+ claimToken: '',
+ claimExpiresAt: null,
+ automationMode: 'manual',
+ requiresClaim: true,
+ userActionStatus: 'pending_claim',
+ attemptCount: 0,
+ lastError: '',
+ contextJson: JSON.stringify({
+ profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
+ profileName: String(profile.name || ''),
+ skuCode,
+ skuName,
+ inventorySkuCode: skuCode,
+ primaryRequirement: primaryRequirement
+ ? {
+ roleKey,
+ credentialType,
+ }
+ : null,
+ manualRedeem: manualContext,
+ }),
+ createdAt: now,
+ updatedAt: now,
+ })
+
+ if (!createdTask) {
+ throw createHttpError('人工兑换任务创建失败', {
+ statusCode: 500,
+ errorCode: 'admin_manual_redeem_task_create_failed',
+ })
+ }
+
+ const reservedInventory = await reserveInventoryForTask({
+ skuCode,
+ taskId: createdTask.id,
+ credentialType,
+ roleKey,
+ })
+
+ if (!reservedInventory) {
+ await updateTask(createdTask.id, {
+ task_status: 'waiting_inventory',
+ inventory_status: 'pending',
+ last_error: '库存不足,无法为人工兑换任务预占库存',
+ updated_at: nowIso(),
+ })
+
+ throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
+ statusCode: 409,
+ errorCode: 'admin_manual_redeem_inventory_race_lost',
+ })
+ }
+
+ const claimToken = await createTaskClaimToken(createdTask.id)
+ await updateTask(createdTask.id, {
+ task_status: 'link_generated',
+ inventory_status: 'reserved',
+ claim_token: claimToken.token,
+ claim_expires_at: claimToken.expired_at,
+ user_action_status: 'pending_claim',
+ last_error: '',
+ updated_at: nowIso(),
+ })
+
+ await createTaskEvent(createdTask.id, 'manual_redeem_created', {
+ sourceType: MANUAL_SOURCE_TYPE,
+ proofValue,
+ skuCode,
+ skuName,
+ inventoryItemId: Number(reservedInventory.id || 0) || null,
+ createdBy: manualContext.createdBy,
+ remark,
+ }, nowIso())
+
+ return getAdminManualRedeemDetail(createdTask.id)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+export async function getAdminManualRedeemDetail(taskId) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ const detail = await getClaimDetailForAdminTask(task.id)
+ return decorateManualRedeemDetail(task, detail)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+/** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */
+export async function createAdminManualRedeemSession(taskId, payload = {}) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ const detail = await createClaimSessionForAdminTask(task.id, payload)
+ return decorateManualRedeemDetail(task, detail)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+export async function getAdminManualRedeemSessionSummary(taskId) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ const detail = await getClaimSessionSummaryForAdminTask(task.id)
+ return decorateManualRedeemDetail(task, detail)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+export async function reloadAdminManualRedeemSession(taskId) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ const detail = await reloadClaimSessionForAdminTask(task.id)
+ return decorateManualRedeemDetail(task, detail)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+export async function closeAdminManualRedeemSession(taskId) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ const detail = await closeClaimSessionForAdminTask(task.id)
+ return decorateManualRedeemDetail(task, detail)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+export async function closeAdminManualRedeemTask(taskId) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ await closeAdminTask(task.id)
+ return getAdminManualRedeemDetail(task.id)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+/** @param {AdminViewerSessionInput | null} [session] */
+export async function confirmAdminManualRedeemRole(taskId, session = null) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ await confirmAdminTaskAssistedRole(task.id, session)
+ return getAdminManualRedeemDetail(task.id)
+}
+
+/** @param {AdminEntityIdInput} taskId */
+/** @param {AdminViewerSessionInput | null} [session] */
+export async function redeemAdminManualRedeemTask(taskId, session = null) {
+ const task = await getRequiredManualRedeemTask(taskId)
+ await redeemAdminTaskAssisted(task.id, session)
+ return getAdminManualRedeemDetail(task.id)
+}
+
+async function getRequiredManualRedeemTask(taskId) {
+ const task = await getTaskById(Number(taskId))
+
+ if (!task) {
+ throw createHttpError('人工兑换任务不存在', {
+ statusCode: 404,
+ errorCode: 'admin_manual_redeem_task_not_found',
+ })
+ }
+
+ const taskContext = parseTaskContext(task)
+ const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
+
+ if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
+ throw createHttpError('当前任务不是人工兑换任务', {
+ statusCode: 409,
+ errorCode: 'admin_manual_redeem_task_invalid',
+ })
+ }
+
+ return task
+}
+
+function decorateManualRedeemDetail(task, detail) {
+ const taskContext = parseTaskContext(task)
+ const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
+ ? taskContext.manualRedeem
+ : {}
+
+ return {
+ ...detail,
+ claimUrl: '',
+ manualRequest: {
+ sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
+ proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
+ remark: String(manualRedeem.remark || '').trim(),
+ },
+ result: detail.result
+ ? {
+ ...detail.result,
+ screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
+ }
+ : null,
+ }
+}
+
+function normalizeManualProofValue(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim()
+}
diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js
index d6b8d1cd..77a1e1a5 100644
--- a/apps/backend/src/services/claim/claim-session-service.js
+++ b/apps/backend/src/services/claim/claim-session-service.js
@@ -44,6 +44,20 @@ export async function getClaimDetail(token, { includeQrImage = true } = {}) {
})
}
+export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) {
+ const context = await getClaimContextByTaskId(taskId)
+ const { task, session } = await loadTaskSession(context.task, { includeQrImage })
+ const syncedTask = session ? await syncTaskWithSession(task, session) : task
+
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task: syncedTask,
+ order: context.order,
+ orderItem: context.orderItem,
+ session,
+ })
+}
+
export async function createClaimSession(token, payload = {}) {
const context = await getClaimContext(token)
assertTaskCanProceed(context.task)
@@ -106,6 +120,68 @@ export async function createClaimSession(token, payload = {}) {
})
}
+export async function createClaimSessionForAdminTask(taskId, payload = {}) {
+ const context = await getClaimContextByTaskId(taskId)
+ assertTaskCanProceed(context.task)
+ const requestedLoginType = normalizeClaimLoginType(payload.loginType)
+ const forceRecreate = Boolean(payload.forceRecreate)
+ let task = context.task
+
+ if (task.browser_session_id) {
+ const existing = await loadTaskSession(task, { includeQrImage: true })
+ task = existing.task
+
+ if (existing.session) {
+ const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type)
+
+ if (!forceRecreate && existingLoginType === requestedLoginType) {
+ const syncedTask = await syncTaskWithSession(task, existing.session)
+
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task: syncedTask,
+ order: context.order,
+ orderItem: context.orderItem,
+ session: existing.session,
+ })
+ }
+
+ try {
+ await closeTencentBrowserSession(existing.session.sessionId)
+ } catch (error) {
+ if (!isRecoverableSessionError(error)) {
+ throw error
+ }
+ }
+
+ task = await clearTaskSession(task, {
+ lastError: '',
+ })
+ }
+ }
+
+ const session = await createTencentBrowserSession({
+ loginType: requestedLoginType,
+ })
+ const updatedTask = await updateTask(task.id, {
+ task_status: 'claimed',
+ browser_session_id: session.sessionId,
+ login_type: session.loginType,
+ user_action_status: 'claimed',
+ claimed_at: task.claimed_at || nowIso(),
+ updated_at: nowIso(),
+ last_error: '',
+ })
+
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task: updatedTask,
+ order: context.order,
+ orderItem: context.orderItem,
+ session,
+ })
+}
+
export async function getClaimSessionSummary(token) {
const context = await getClaimContext(token)
const { task, session } = await loadTaskSession(context.task, {
@@ -133,6 +209,33 @@ export async function getClaimSessionSummary(token) {
})
}
+export async function getClaimSessionSummaryForAdminTask(taskId) {
+ const context = await getClaimContextByTaskId(taskId)
+ const { task, session } = await loadTaskSession(context.task, {
+ includeQrImage: false,
+ })
+
+ if (!session) {
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task,
+ order: context.order,
+ orderItem: context.orderItem,
+ session: null,
+ })
+ }
+
+ const syncedTask = await syncTaskWithSession(task, session)
+
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task: syncedTask,
+ order: context.order,
+ orderItem: context.orderItem,
+ session,
+ })
+}
+
export async function reloadClaimSession(token) {
const context = await getClaimContext(token)
assertTaskCanProceed(context.task)
@@ -163,6 +266,36 @@ export async function reloadClaimSession(token) {
})
}
+export async function reloadClaimSessionForAdminTask(taskId) {
+ const context = await getClaimContextByTaskId(taskId)
+ assertTaskCanProceed(context.task)
+
+ const active = await loadTaskSession(context.task, {
+ includeQrImage: true,
+ })
+
+ if (!active.session) {
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task: active.task,
+ order: context.order,
+ orderItem: context.orderItem,
+ session: null,
+ })
+ }
+
+ const session = await reloadTencentBrowserSession(active.session.sessionId)
+ const syncedTask = await syncTaskWithSession(active.task, session)
+
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task: syncedTask,
+ order: context.order,
+ orderItem: context.orderItem,
+ session,
+ })
+}
+
export async function closeClaimSession(token) {
const context = await getClaimContext(token)
assertTaskCanProceed(context.task)
@@ -199,6 +332,42 @@ export async function closeClaimSession(token) {
})
}
+export async function closeClaimSessionForAdminTask(taskId) {
+ const context = await getClaimContextByTaskId(taskId)
+ assertTaskCanProceed(context.task)
+ let task = context.task
+
+ if (!task.browser_session_id) {
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task,
+ order: context.order,
+ orderItem: context.orderItem,
+ session: null,
+ })
+ }
+
+ try {
+ await closeTencentBrowserSession(task.browser_session_id)
+ } catch (error) {
+ if (!isRecoverableSessionError(error)) {
+ throw error
+ }
+ }
+
+ task = await clearTaskSession(task, {
+ lastError: '',
+ })
+
+ return buildClaimDetailPayload({
+ claimToken: context.claimToken,
+ task,
+ order: context.order,
+ orderItem: context.orderItem,
+ session: null,
+ })
+}
+
export async function confirmClaimRole(token) {
const context = await getClaimContext(token)
assertPublicClaimActionAllowed(context.task, 'confirm')
diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js
index 77320725..e7494e47 100644
--- a/apps/backend/src/types/admin-route-inputs.js
+++ b/apps/backend/src/types/admin-route-inputs.js
@@ -58,6 +58,10 @@ export {}
* @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody
*/
+/**
+ * @typedef {import('./admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateRouteBody
+ */
+
/**
* @typedef {{ inventoryItemId?: string }} AdminInventoryRouteParams
*/
@@ -70,6 +74,10 @@ export {}
* @typedef {{ taskId?: string, bindingId?: string }} AdminTaskRouteParams
*/
+/**
+ * @typedef {{ taskId?: string }} AdminManualRedeemRouteParams
+ */
+
/**
* @typedef {{ eventId?: string }} AdminWebhookEventRouteParams
*/
diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js
index 1ef4622e..9e8a7f7c 100644
--- a/apps/backend/src/types/admin-write-inputs.js
+++ b/apps/backend/src/types/admin-write-inputs.js
@@ -107,3 +107,12 @@ export {}
* deliveredCredential?: string
* }} AdminTaskManualDispatchInput
*/
+
+/**
+ * @typedef {{
+ * proofValue?: string
+ * skuCode?: string
+ * skuName?: string
+ * remark?: string
+ * }} AdminManualRedeemCreateInput
+ */
diff --git a/apps/backend/src/types/repository-rows.js b/apps/backend/src/types/repository-rows.js
index dbedf629..3176906e 100644
--- a/apps/backend/src/types/repository-rows.js
+++ b/apps/backend/src/types/repository-rows.js
@@ -63,8 +63,10 @@ export {}
* role_name: string
* role_id: string
* claim_token: string
+ * claim_expires_at?: string | null
* primary_claim_token: string
* primary_claim_token_id: number | null
+ * primary_claim_expires_at?: string | null
* primary_inventory_item_id: number | null
* inventory_display_value: string
* primary_inventory_display_value: string
diff --git a/apps/frontend/src/components/tencent/TencentAuthCard.vue b/apps/frontend/src/components/tencent/TencentAuthCard.vue
index bb6677b7..ee707871 100644
--- a/apps/frontend/src/components/tencent/TencentAuthCard.vue
+++ b/apps/frontend/src/components/tencent/TencentAuthCard.vue
@@ -13,6 +13,8 @@ const props = defineProps<{
loginTabs: LoginTab[]
loginType: TencentLoginType
loginTypeLabel: string
+ qrCopyButtonLabel?: string
+ qrCopyEnabled?: boolean
qrFigureStyle: { width: string; maxWidth: string }
qrImage: string
scanInstruction: string
@@ -24,6 +26,7 @@ const props = defineProps<{
const emit = defineEmits<{
closeSession: []
+ copyQr: []
createSession: [loginType: TencentLoginType]
openQrPreview: []
qrImageLoad: [event: Event]
@@ -53,6 +56,7 @@ const emit = defineEmits<{
:style="props.qrFigureStyle"
type="button"
@click="emit('openQrPreview')"
+ @dblclick.prevent.stop="emit('copyQr')"
>
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
+ 双击二维码可直接复制到剪贴板,也可以使用下方按钮复制。 +
{{
props.sessionNotice ||
@@ -118,6 +128,17 @@ const emit = defineEmits<{
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
+
客服在后台创建唯一任务、发送二维码、复核角色并完成兑换。
+先录入唯一凭据并锁定库存,后续二维码、角色确认和兑换都基于这条任务继续。
++ 客户登录后,系统会把当前角色截图回传到这里。客服复制后可直接发给客户做二次确认。 +
++ 最终兑换完成后,客服可以把结果截图直接发给客户确认。 +
++ {{ screenshotEmptyMessage }} +
+{{ screenshotEmptyMessage }}
+先在上方输入唯一凭据和内部 SKU,系统会立即校验唯一性并预占库存。
+