增加分组库存与客服

This commit is contained in:
yml2213
2026-04-23 13:38:21 +08:00
parent 2a05a68959
commit f0b31121d7
31 changed files with 675 additions and 80 deletions
@@ -7,6 +7,7 @@ import {
getAdminUserById,
getAdminUserByUsername,
listAdminUsers,
replaceAdminUserInventoryGroupBindings,
updateAdminUser,
} from '../../repositories/admin-user-repo.js'
import { addHours, nowIso } from '../../utils/time.js'
@@ -127,6 +128,7 @@ export async function verifyAdminSessionToken(token) {
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
allowedInventoryGroups: normalizeInventoryGroupCodes(user.inventory_group_codes),
expiresAt,
}
}
@@ -141,6 +143,7 @@ export async function getAdminSessionSummary(token) {
userId: session.userId,
username: session.username,
role: session.role,
inventoryGroupCodes: session.allowedInventoryGroups,
},
}
}
@@ -216,6 +219,18 @@ export async function createManagedAdminUser(payload = {}) {
}
}
export async function updateManagedAdminUserInventoryGroups(userId, payload = {}, session) {
const user = await getRequiredAdminUser(userId)
const inventoryGroupCodes = normalizeInventoryGroupCodes(payload.inventoryGroupCodes)
await ensureAdminUserChangeAllowed(user, {}, session)
const updated = await replaceAdminUserInventoryGroupBindings(user.id, inventoryGroupCodes, nowIso())
return {
user: mapAdminUser(updated),
}
}
export async function updateManagedAdminUserRole(userId, payload = {}, session) {
const user = await getRequiredAdminUser(userId)
const role = normalizeAdminRole(payload.role)
@@ -315,6 +330,7 @@ function createAdminSession(user) {
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
},
}
}
@@ -445,7 +461,14 @@ function mapAdminUser(user) {
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
status: normalizeAdminUserStatus(user.status),
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
createdAt: user.created_at,
updatedAt: user.updated_at,
}
}
function normalizeInventoryGroupCodes(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
@@ -23,6 +23,7 @@ export async function mapAdminInventoryListItem(item) {
skuCode: item.sku_code,
batchNo: item.batch_no,
credentialType: item.credential_type || 'tencent_code',
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
displayValue: item.display_value,
status: item.status,
reservedByTaskId: item.reserved_by_task_id,
@@ -19,7 +19,7 @@ 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'
import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
const MANUAL_PROVIDER = 'manual'
const MANUAL_PLATFORM = 'manual_redeem'
@@ -36,6 +36,7 @@ export async function createAdminManualRedeemTask(
payload = /** @type {AdminManualRedeemCreateInput} */ ({}),
session = null,
) {
const viewerContext = createAdminViewerContext(session)
const proofValue = normalizeManualProofValue(payload.proofValue)
const skuCode = String(payload.skuCode || '').trim()
const skuName = String(payload.skuName || '').trim() || skuCode
@@ -81,9 +82,20 @@ export async function createAdminManualRedeemTask(
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)
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(
skuCode,
credentialType,
viewerContext.allowedInventoryGroupCodes,
)
if (!availableInventory) {
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_unavailable',
@@ -208,6 +220,7 @@ export async function createAdminManualRedeemTask(
credentialType,
}
: null,
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
manualRedeem: manualContext,
}),
createdAt: now,
@@ -226,6 +239,7 @@ export async function createAdminManualRedeemTask(
taskId: createdTask.id,
credentialType,
roleKey,
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
if (!reservedInventory) {
@@ -250,6 +264,10 @@ export async function createAdminManualRedeemTask(
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
last_error: '',
context_json: JSON.stringify({
...parseTaskContext(createdTask),
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
}),
updated_at: nowIso(),
})
@@ -259,73 +277,80 @@ export async function createAdminManualRedeemTask(
skuCode,
skuName,
inventoryItemId: Number(reservedInventory.id || 0) || null,
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
createdBy: manualContext.createdBy,
remark,
}, nowIso())
return getAdminManualRedeemDetail(createdTask.id)
return getAdminManualRedeemDetail(createdTask.id, session)
}
/** @param {AdminEntityIdInput} taskId */
export async function getAdminManualRedeemDetail(taskId) {
const task = await getRequiredManualRedeemTask(taskId)
/** @param {AdminViewerSessionInput | null} [session] */
export async function getAdminManualRedeemDetail(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
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)
/** @param {AdminViewerSessionInput | null} [session] */
export async function createAdminManualRedeemSession(taskId, payload = {}, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await createClaimSessionForAdminTask(task.id, payload)
return decorateManualRedeemDetail(task, detail)
}
/** @param {AdminEntityIdInput} taskId */
export async function getAdminManualRedeemSessionSummary(taskId) {
const task = await getRequiredManualRedeemTask(taskId)
/** @param {AdminViewerSessionInput | null} [session] */
export async function getAdminManualRedeemSessionSummary(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimSessionSummaryForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
/** @param {AdminEntityIdInput} taskId */
export async function reloadAdminManualRedeemSession(taskId) {
const task = await getRequiredManualRedeemTask(taskId)
/** @param {AdminViewerSessionInput | null} [session] */
export async function reloadAdminManualRedeemSession(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await reloadClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
/** @param {AdminEntityIdInput} taskId */
export async function closeAdminManualRedeemSession(taskId) {
const task = await getRequiredManualRedeemTask(taskId)
/** @param {AdminViewerSessionInput | null} [session] */
export async function closeAdminManualRedeemSession(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await closeClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
/** @param {AdminEntityIdInput} taskId */
export async function closeAdminManualRedeemTask(taskId) {
const task = await getRequiredManualRedeemTask(taskId)
/** @param {AdminViewerSessionInput | null} [session] */
export async function closeAdminManualRedeemTask(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
await closeAdminTask(task.id)
return getAdminManualRedeemDetail(task.id)
return getAdminManualRedeemDetail(task.id, session)
}
/** @param {AdminEntityIdInput} taskId */
/** @param {AdminViewerSessionInput | null} [session] */
export async function confirmAdminManualRedeemRole(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId)
const task = await getRequiredManualRedeemTask(taskId, session)
await confirmAdminTaskAssistedRole(task.id, session)
return getAdminManualRedeemDetail(task.id)
return getAdminManualRedeemDetail(task.id, session)
}
/** @param {AdminEntityIdInput} taskId */
/** @param {AdminViewerSessionInput | null} [session] */
export async function redeemAdminManualRedeemTask(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId)
const task = await getRequiredManualRedeemTask(taskId, session)
await redeemAdminTaskAssisted(task.id, session)
return getAdminManualRedeemDetail(task.id)
return getAdminManualRedeemDetail(task.id, session)
}
async function getRequiredManualRedeemTask(taskId) {
async function getRequiredManualRedeemTask(taskId, session = null) {
const task = await getTaskById(Number(taskId))
if (!task) {
@@ -345,6 +370,25 @@ async function getRequiredManualRedeemTask(taskId) {
})
}
const viewerContext = createAdminViewerContext(session)
if (viewerContext.restrictInventoryGroups) {
if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) {
throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_denied',
})
}
}
return task
}
@@ -2,7 +2,11 @@
import { buildClaimUrl } from '../claim/claim-service.js'
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import { getInventoryItemById, listInventoryItems, listInventorySkuSuggestions } from '../../repositories/inventory-repo.js'
import {
getInventoryItemById,
listInventoryItems,
listInventorySkuSuggestions,
} from '../../repositories/inventory-repo.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js'
@@ -313,9 +317,11 @@ export async function getAdminTaskScreenshotPath(taskId, session = null) {
/** @returns {Promise<AdminInventoryListResponse>} */
/** @param {AdminInventoryListQueryInput} [query] */
export async function getAdminInventoryItems(query = /** @type {AdminInventoryListQueryInput} */ ({})) {
/** @param {AdminViewerSessionInput | null} [session] */
export async function getAdminInventoryItems(query = /** @type {AdminInventoryListQueryInput} */ ({}), session = null) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const viewerContext = createAdminViewerContext(session)
const { items, total } = await listInventoryItems({
page,
pageSize,
@@ -323,6 +329,8 @@ export async function getAdminInventoryItems(query = /** @type {AdminInventoryLi
credentialType: String(query.credentialType || '').trim(),
status: String(query.status || '').trim(),
batchNo: String(query.batchNo || '').trim(),
inventoryGroupCode: String(query.inventoryGroupCode || '').trim(),
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
return {
@@ -335,9 +343,12 @@ export async function getAdminInventoryItems(query = /** @type {AdminInventoryLi
/** @param {AdminInventorySkuSuggestionQueryInput} [query] */
export async function getAdminInventorySkuSuggestions(
query = /** @type {AdminInventorySkuSuggestionQueryInput} */ ({}),
session = null,
) {
const viewerContext = createAdminViewerContext(session)
const credentialType = String(query.credentialType || '').trim()
const keyword = String(query.keyword || '').trim()
const inventoryGroupCode = String(query.inventoryGroupCode || '').trim()
const requestedLimit = Number(query.limit)
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(100, Math.floor(requestedLimit))
@@ -346,12 +357,15 @@ export async function getAdminInventorySkuSuggestions(
credentialType,
keyword,
limit,
inventoryGroupCode,
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
return {
items: items.map((item) => ({
skuCode: String(item.sku_code || '').trim(),
credentialType: String(item.credential_type || '').trim() || 'tencent_code',
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
totalCount: Number(item.total_count || 0),
availableCount: Number(item.available_count || 0),
latestUpdatedAt: item.latest_updated_at || null,
@@ -57,9 +57,14 @@ export function getTaskPrimaryClaimTokenId(task) {
export function createAdminViewerContext(session = null) {
const role = normalizeAdminRole(session?.role)
const allowedInventoryGroupCodes = role === 'support'
? normalizeInventoryGroupCodes(session?.allowedInventoryGroups)
: null
return {
role,
allowedInventoryGroupCodes,
restrictInventoryGroups: role === 'support',
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
canManageSensitiveInventory: role === 'admin',
canManageTaskLifecycle: role === 'admin' || role === 'operator',
@@ -200,11 +205,13 @@ export function canRegenerateClaimLinkForViewer(task, viewerContext) {
return true
}
return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task)
return viewerContext.canOperateAssistedTask
&& isAssistedClaimTask(task)
&& canViewerAccessTaskInventoryGroup(task, viewerContext)
}
export function canViewerConfirmAssistedRole(task, viewerContext) {
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) {
return false
}
@@ -212,13 +219,42 @@ export function canViewerConfirmAssistedRole(task, viewerContext) {
}
export function canViewerRedeemAssistedTask(task, viewerContext) {
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) {
return false
}
return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim())
}
export function canViewerAccessTaskInventoryGroup(task, viewerContext = createAdminViewerContext()) {
if (!viewerContext.restrictInventoryGroups) {
return true
}
const allowedInventoryGroupCodes = Array.isArray(viewerContext.allowedInventoryGroupCodes)
? viewerContext.allowedInventoryGroupCodes
: []
if (allowedInventoryGroupCodes.length === 0) {
return false
}
const taskContext = parseTaskContext(task)
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
if (!inventoryGroupCode) {
return true
}
return allowedInventoryGroupCodes.includes(inventoryGroupCode)
}
export function normalizeInventoryGroupCodes(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
function mapRedeemResolutionAttempt(value) {
if (!value || typeof value !== 'object') {
return null
@@ -2,6 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
canViewerConfirmAssistedRole,
canViewerCloseTask,
createAdminViewerContext,
resolveAdminTaskScreenshotUrl,
@@ -37,3 +38,18 @@ test('canViewerCloseTask allows support to close active task but not redeemed ta
assert.equal(canViewerCloseTask({ task_status: 'link_generated' }, viewerContext), true)
assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false)
})
test('canViewerConfirmAssistedRole blocks support when task inventory group is not bound', () => {
const viewerContext = createAdminViewerContext({
role: 'support',
allowedInventoryGroups: ['A组'],
})
assert.equal(canViewerConfirmAssistedRole({
task_status: 'claimed',
executor_key: 'tencent_claim_assisted',
context_json: {
inventoryGroupCode: 'B组',
},
}, viewerContext), false)
})
@@ -64,6 +64,7 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento
const displayValue = String(payload.displayValue || '').trim()
const batchNo = String(payload.batchNo || '').trim()
const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code'
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
if (!skuCode || !displayValue) {
throw createHttpError('缺少 skuCode 或 displayValue', {
@@ -79,6 +80,7 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento
displayValue,
batchNo,
credentialType,
inventoryGroupCode,
createdAt: now,
updatedAt: now,
},
@@ -93,10 +95,11 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento
const { items } = await listInventoryItems({
page: 1,
pageSize: 1,
pageSize: 20,
skuCode,
inventoryGroupCode,
})
const createdItem = items.find((item) => item.display_value === displayValue) || null
const createdItem = items.find((item) => item.display_value === displayValue && item.inventory_group_code === inventoryGroupCode) || null
return {
inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null,
@@ -121,6 +124,7 @@ export async function importAdminInventoryItems(payload = /** @type {AdminInvent
batchNo: row.batchNo,
displayValue: row.displayValue,
credentialType: row.credentialType,
inventoryGroupCode: row.inventoryGroupCode,
createdAt: now,
updatedAt: now,
}))
@@ -538,6 +542,7 @@ export async function retryAdminTask(taskId) {
taskId: task.id,
credentialType: primaryRequirement?.credentialType || 'tencent_code',
roleKey: primaryRequirement?.roleKey || 'primary_code',
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
})
if (!reserved) {
@@ -672,7 +677,7 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess
/**
* @param {AdminInventoryImportInput} payload
* @returns {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType'>>>}
* @returns {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'>>>}
*/
function normalizeInventoryImportRows(payload) {
const rows = []
@@ -685,12 +690,13 @@ function normalizeInventoryImportRows(payload) {
const displayValue = String(row?.displayValue || '').trim()
const batchNo = String(row?.batchNo || '').trim()
const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code'
const inventoryGroupCode = String(row?.inventoryGroupCode || payload.inventoryGroupCode || '').trim()
if (!skuCode || !displayValue) {
continue
}
rows.push({ skuCode, displayValue, batchNo, credentialType })
rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
}
}
@@ -698,6 +704,7 @@ function normalizeInventoryImportRows(payload) {
const skuCode = String(payload.skuCode || '').trim()
const batchNo = String(payload.batchNo || '').trim()
const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code'
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
if (!skuCode) {
throw createHttpError('批量导入时缺少 skuCode', {
@@ -711,7 +718,7 @@ function normalizeInventoryImportRows(payload) {
if (!displayValue) {
continue
}
rows.push({ skuCode, displayValue, batchNo, credentialType })
rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
}
}
@@ -719,7 +726,7 @@ function normalizeInventoryImportRows(payload) {
}
/**
* @param {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType'>>>} rows
* @param {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'>>>} rows
*/
function dedupeRows(rows) {
const seen = new Set()
@@ -737,6 +744,12 @@ function dedupeRows(rows) {
return output
}
function resolveTaskInventoryGroupCodes(task) {
const taskContext = parseTaskContext(task)
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
return inventoryGroupCode ? [inventoryGroupCode] : null
}
/**
* @param {Awaited<ReturnType<typeof getRequiredTask>>} task