增加 履约配置 手动查询

This commit is contained in:
yml2213
2026-04-13 16:46:33 +08:00
parent 050671e71b
commit 96d1644b1c
6 changed files with 461 additions and 29 deletions
+17 -11
View File
@@ -1,16 +1,22 @@
https://cdk.linux.do/receive/069af7bc-7915-48b6-834b-cf52eb00b798
这条“半自动+人工”链路已经落下第一版了。
后端现在新增了 support 角色,并把半自动档案 tencent_claim_assisted 接进了履约目录。用户侧领取页现在只允许“创建登录会话 + 扫码登录”,如果任务是半自动客服模式,用户自己再点“确认角色/开始兑换”会被后端直接拦住;客服则可以在任务详情里拿领取链接、看登录后的复核截图、确认角色、再触发后端自动兑换。关键入口在 admin-service.js、claim-session-service.js、session.js、fulfillment-bootstrap-service.js。
cloudflared tunnel create mac-mini-tunnel
权限也一起收紧了。support 看不到库存凭据、原始 claim token、库存管理、Webhook、配置、审计、用户管理;任务详情接口本身已经做了脱敏,不只是前端隐藏。前端这边补了客服角色导航、路由限制和任务操作按钮,配置页也新增了“腾讯领取兑换(半自动+人工)”选项,相关界面在 AdminTaskDetailView.vue、AdminLayout.vue、AdminFulfillmentBindingsView.vue、AdminUsersView.vue。
yml@ymlMacBook-Air ~ % cloudflared tunnel create mac-mini-tunnel
Tunnel credentials written to /Users/yml/.cloudflared/562f9639-71f2-44e0-a5ef-b34bd4e215e5.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.
我做了后端文件的语法检查,node --check 通过;前端没法做完整 typecheck,因为当前环境里 vue-tsc 没装,npm run typecheck 会直接报 vue-tsc: command not found。
你现在可以直接这样试:
Created tunnel mac-mini-tunnel with id 562f9639-71f2-44e0-a5ef-b34bd4e215e5
yml@ymlMacBook-Air ~ %
后台用户里新增一个 support 账号。
在履约配置里把目标商品改成 tencent_claim_assisted。
下一个测试单后,让用户打开领取链接并扫码登录。
客服进入任务详情,确认能看到领取链接和复核截图,但看不到兑换码。
客服点“客服确认角色”再点“客服开始兑换”。
如果你愿意,我下一步可以继续把“任务列表页也做客服专用态优化”以及“客服复核截图/角色状态做成更明显的状态块”。
221329.cc.cd
4. # 例子:把 web.yourdomain.com 指向刚才创建的隧道
cloudflared tunnel route dns mac-mini-tunnel 221329.cc.cd
5. 启动隧道(临时/测试)
如果你只是临时跑一个服务(比如你 OrbStack 里的容器映射到了本地 80 端口):
cloudflared tunnel run --url http://localhost:80 mac-mini-tunnel
@@ -3,6 +3,7 @@ import { Router } from 'express'
import {
getAdminAgisoShopConfigs,
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminAgisoShopConfigs,
updateAdminFulfillmentBindingConfigs,
} from '../../services/admin/admin-service.js'
@@ -48,6 +49,30 @@ router.get('/platform-config/fulfillment-bindings', createJsonHandler(
},
))
router.post('/platform-config/fulfillment-bindings/lookup-order', createJsonHandler(
(req) => lookupAdminFulfillmentBindingOrder(req.body),
{
successMessage: '订单商品查询成功',
errorMessage: '手动查询订单商品失败',
scope: '[admin/platform-config/fulfillment-bindings/lookup-order]',
audit: (req, data) => ({
action: 'platform_fulfillment_order_lookup',
targetType: 'platform_config',
targetId: [
data.order?.provider || 'unknown',
data.order?.platform || 'unknown',
data.order?.shopId || 'unknown',
data.order?.platformOrderId || 'unknown',
].join(':'),
data: {
itemCount: Array.isArray(data.items) ? data.items.length : 0,
shopId: data.order?.shopId || String(req.body?.shopId || '').trim(),
platformOrderId: data.order?.platformOrderId || String(req.body?.platformOrderId || '').trim(),
},
}),
},
))
router.post('/platform-config/fulfillment-bindings', createJsonHandler(
(req) => updateAdminFulfillmentBindingConfigs(req.body),
{
@@ -8,6 +8,7 @@ import {
getAgisoShopsFilePath,
saveAgisoShopConfigMap,
} from '../platforms/agiso/shop-config-service.js'
import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js'
import {
getOrderFulfillmentBindingConfigs,
getOrderFulfillmentBindingsFilePath,
@@ -688,8 +689,7 @@ export async function getAdminFulfillmentBindingConfigs() {
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
observedProducts: rowsResult.rows.map((row) => {
const item = {
observedProducts: rowsResult.rows.map((row) => mapAdminObservedProductItem({
provider: String(row.provider || '').trim(),
platform: String(row.platform || '').trim(),
shopId: String(row.shop_id || '').trim(),
@@ -699,11 +699,128 @@ export async function getAdminFulfillmentBindingConfigs() {
externalSkuName: String(row.external_sku_name || '').trim(),
latestSeenAt: row.latest_seen_at || null,
orderItemCount: Number(row.order_item_count || 0),
}, bindings)),
}
}
export async function lookupAdminFulfillmentBindingOrder(payload = {}) {
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
const shopId = String(payload.shopId || '').trim()
const platformOrderId = String(payload.platformOrderId || '').trim()
if (!shopId) {
throw createHttpError('请先填写店铺 ID', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
})
}
if (!platformOrderId) {
throw createHttpError('请先填写平台订单号', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
})
}
if (provider !== 'agiso' || platform !== 'xianyu') {
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
})
}
const detailResult = await enrichAgisoXianyuTradeOrder({
provider,
platform,
shopId,
shopName: '',
platformOrderId,
orderStatus: 'created',
payStatus: 'unpaid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
paidAt: null,
rawPayload: {},
items: [],
}, {
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
})
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
const items = Array.isArray(detail.items) ? detail.items : []
if (items.length === 0) {
const detailReason = String(detailResult?.reason || '').trim()
const detailMessage = String(detailResult?.errorMessage || '').trim()
let message = detailMessage
if (!message && detailReason === 'missing_config') {
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
}
if (!message) {
message = `未查询到订单 ${platformOrderId} 的商品明细`
}
throw createHttpError(message, {
statusCode: 404,
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
})
}
const bindings = getOrderFulfillmentBindingConfigs()
const resolvedShopName = pickFirstNonEmpty([
detail.shopName,
getAgisoShopConfig(shopId)?.shopName,
])
return {
order: {
provider,
platform,
shopId,
shopName: resolvedShopName,
platformOrderId,
buyerName: String(detail.buyerName || '').trim(),
totalAmountFen: Number(detail.totalAmount || 0),
totalAmount: formatFenToAmount(detail.totalAmount),
paidAt: detail.paidAt || null,
enriched: Boolean(detailResult?.enriched),
enrichReason: String(detailResult?.reason || '').trim(),
errorMessage: String(detailResult?.errorMessage || '').trim(),
},
items: items.map((item, index) => {
const observed = {
provider,
platform,
shopId,
shopName: resolvedShopName,
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
externalSkuCode: pickFirstNonEmpty([item?.externalSkuCode, item?.skuCode, item?.externalItemId, item?.itemId]),
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
latestSeenAt: null,
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
}
return {
...item,
configured: bindings.some((binding) => matchesObservedProduct(binding, item)),
lineId: [
platformOrderId,
index + 1,
observed.externalSkuCode || 'na',
observed.externalItemId || 'na',
].join(':'),
itemTitle: pickFirstNonEmpty([
item?.skuName,
item?.externalSkuName,
item?.externalSkuCode,
item?.externalItemId,
]),
quantity: observed.orderItemCount,
...mapAdminObservedProductItem(observed, bindings),
}
}),
}
@@ -1253,6 +1370,34 @@ function matchesObservedProduct(binding, observed) {
)
}
function mapAdminObservedProductItem(item, bindings = []) {
const matchedBinding = findMatchingObservedBinding(bindings, item)
return {
provider: String(item?.provider || '').trim(),
platform: String(item?.platform || '').trim(),
shopId: String(item?.shopId || '').trim(),
shopName: String(item?.shopName || '').trim(),
externalItemId: String(item?.externalItemId || '').trim(),
externalSkuCode: String(item?.externalSkuCode || '').trim(),
externalSkuName: String(item?.externalSkuName || '').trim(),
latestSeenAt: item?.latestSeenAt || null,
orderItemCount: Number(item?.orderItemCount || 0),
configured: Boolean(matchedBinding),
matchedBinding: matchedBinding
? {
skuCode: String(matchedBinding.skuCode || '').trim(),
skuName: String(matchedBinding.skuName || '').trim(),
profileKey: String(matchedBinding.profileKey || '').trim(),
}
: null,
}
}
function findMatchingObservedBinding(bindings, observed) {
return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null
}
async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
const headers = normalizeRecord(safeParseJson(item.headers_json))
const query = normalizeRecord(safeParseJson(item.query_json))
+10
View File
@@ -3,6 +3,7 @@ import type {
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
AdminAuditLogItem,
AdminFulfillmentLookupResult,
AdminFulfillmentBindingConfigItem,
AdminInventoryItemListItem,
AdminMessageDeliveryListItem,
@@ -85,6 +86,15 @@ export function fetchAdminFulfillmentBindingConfigs() {
}>('/api/v1/admin/platform-config/fulfillment-bindings')
}
export function lookupAdminFulfillmentBindingOrder(payload: {
provider?: string
platform?: string
shopId: string
platformOrderId: string
}) {
return apiPost<AdminFulfillmentLookupResult>('/api/v1/admin/platform-config/fulfillment-bindings/lookup-order', payload)
}
export function saveAdminFulfillmentBindingConfigs(payload: { bindings: Array<Record<string, unknown>> }) {
return apiPost<{
filePath: string
+31
View File
@@ -98,6 +98,37 @@ export interface AdminObservedProductItem {
latestSeenAt: string | null
orderItemCount: number
configured: boolean
matchedBinding: null | {
skuCode: string
skuName: string
profileKey: string
}
}
export interface AdminFulfillmentLookupOrder {
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
buyerName: string
totalAmount: string
totalAmountFen: number
paidAt: string | null
enriched: boolean
enrichReason: string
errorMessage: string
}
export interface AdminFulfillmentLookupItem extends AdminObservedProductItem {
lineId: string
itemTitle: string
quantity: number
}
export interface AdminFulfillmentLookupResult {
order: AdminFulfillmentLookupOrder
items: AdminFulfillmentLookupItem[]
}
export interface AdminDashboardSummary {
@@ -1,12 +1,18 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { nextTick, onMounted, reactive, ref } from 'vue'
import { showSuccess } from '@/lib/feedback'
import {
fetchAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
saveAdminFulfillmentBindingConfigs,
} from '@/services/admin'
import type { AdminFulfillmentBindingConfigItem, AdminObservedProductItem } from '@/types/admin'
import type {
AdminFulfillmentBindingConfigItem,
AdminFulfillmentLookupItem,
AdminFulfillmentLookupResult,
AdminObservedProductItem,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
@@ -65,9 +71,22 @@ const errorMessage = ref('')
const filePath = ref('')
const bindings = ref<EditableBinding[]>([])
const observedProducts = ref<AdminObservedProductItem[]>([])
const lookupLoading = ref(false)
const lookupErrorMessage = ref('')
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
const importedLookupLineIds = ref<string[]>([])
const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
const validationState = ref<ValidationState>(null)
const collapsedBindingIds = ref<string[]>([])
const lookupForm = reactive({
shopId: '',
platformOrderId: '',
})
type ImportableProductCandidate = Pick<
AdminObservedProductItem,
'provider' | 'platform' | 'shopId' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
>
function createEmptyBinding(): EditableBinding {
return {
@@ -198,9 +217,8 @@ function removeBinding(id: string) {
bindings.value = bindings.value.filter((item) => item.id !== id)
}
function importObservedProduct(item: AdminObservedProductItem) {
validationState.value = null
const nextBinding = {
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
return {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
@@ -215,10 +233,26 @@ function importObservedProduct(item: AdminObservedProductItem) {
externalSkuName: item.externalSkuName,
resolvedSkuName: item.externalSkuName,
}
}
function importObservedProduct(item: ImportableProductCandidate) {
validationState.value = null
const nextBinding = createBindingFromProductCandidate(item)
setBindingCollapsed(nextBinding.id, false)
bindings.value.unshift(nextBinding)
}
function importLookupProduct(item: AdminFulfillmentLookupItem) {
importObservedProduct(item)
if (!importedLookupLineIds.value.includes(item.lineId)) {
importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId]
}
}
function isLookupProductImported(lineId: string) {
return importedLookupLineIds.value.includes(lineId)
}
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
return {
provider: item.provider.trim() || 'agiso',
@@ -380,6 +414,35 @@ async function saveConfigs() {
}
}
async function lookupOrderProducts() {
const shopId = lookupForm.shopId.trim()
const platformOrderId = lookupForm.platformOrderId.trim()
if (!shopId || !platformOrderId) {
lookupErrorMessage.value = '请先填写店铺 ID 和平台订单号'
return
}
lookupLoading.value = true
lookupErrorMessage.value = ''
try {
const response = await lookupAdminFulfillmentBindingOrder({
provider: 'agiso',
platform: 'xianyu',
shopId,
platformOrderId,
})
lookupResult.value = response.data
importedLookupLineIds.value = []
} catch (error) {
lookupResult.value = null
lookupErrorMessage.value = error instanceof Error ? error.message : '订单商品查询失败'
} finally {
lookupLoading.value = false
}
}
onMounted(loadConfigs)
</script>
@@ -443,6 +506,119 @@ onMounted(loadConfigs)
<div v-if="loading" class="empty-block">履约配置加载中</div>
<template v-else>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>手动查询订单商品</h3>
<p>新商品还没配规则时可以按店铺 ID 和平台订单号临时补查不需要恢复全量订单详情补查</p>
</div>
</div>
<div class="lookup-grid">
<label class="field-block">
<span>渠道</span>
<input class="text-input" value="agiso" disabled />
</label>
<label class="field-block">
<span>平台</span>
<input class="text-input" value="xianyu" disabled />
</label>
<label class="field-block">
<span>店铺 ID</span>
<input
v-model="lookupForm.shopId"
class="text-input"
placeholder="例如 252609"
@keydown.enter.prevent="lookupOrderProducts"
/>
</label>
<label class="field-block">
<span>平台订单号</span>
<input
v-model="lookupForm.platformOrderId"
class="text-input"
placeholder="输入需要补查的订单号"
@keydown.enter.prevent="lookupOrderProducts"
/>
</label>
</div>
<div class="lookup-actions">
<el-button :loading="lookupLoading" round type="primary" @click="lookupOrderProducts">查询订单商品</el-button>
<span class="cell-subtle">当前仅支持 Agiso 咸鱼订单查询结果只用于补配置不会写入订单库</span>
</div>
<p v-if="lookupErrorMessage" class="error-copy lookup-error">{{ lookupErrorMessage }}</p>
<div v-else-if="lookupLoading" class="empty-inline">正在查询订单详情</div>
<template v-else-if="lookupResult">
<div class="lookup-summary">
<div class="lookup-summary-item">
<span class="meta-label">订单</span>
<strong>{{ lookupResult.order.platformOrderId }}</strong>
<span class="cell-subtle">{{ lookupResult.order.shopName || lookupResult.order.shopId || '-' }}</span>
</div>
<div class="lookup-summary-item">
<span class="meta-label">买家</span>
<strong>{{ lookupResult.order.buyerName || '-' }}</strong>
<span class="cell-subtle">支付时间{{ formatAdminDateTime(lookupResult.order.paidAt) }}</span>
</div>
<div class="lookup-summary-item">
<span class="meta-label">金额</span>
<strong>{{ lookupResult.order.totalAmount || '0.00' }}</strong>
<span class="cell-subtle">补查结果{{ lookupResult.order.enriched ? '已补全' : lookupResult.order.enrichReason || '已返回' }}</span>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>外部商品</th>
<th>数量</th>
<th>识别结果</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in lookupResult.items" :key="item.lineId">
<td>
<div class="cell-stack">
<strong>{{ item.externalSkuName || item.itemTitle || item.externalSkuCode || item.externalItemId || '-' }}</strong>
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
</div>
</td>
<td>{{ item.quantity }}</td>
<td>
<div class="cell-stack">
<strong>{{ item.configured ? '已配置' : '未配置' }}</strong>
<span v-if="item.matchedBinding" class="cell-subtle">
{{ item.matchedBinding.skuName || item.matchedBinding.skuCode || '-' }} / {{ item.matchedBinding.profileKey || '-' }}
</span>
<span v-else class="cell-subtle">可直接导入成新规则草稿</span>
</div>
</td>
<td>
<el-button
v-if="!item.configured && !isLookupProductImported(item.lineId)"
link
type="primary"
@click="importLookupProduct(item)"
>
导入到规则
</el-button>
<span v-else-if="item.configured" class="cell-subtle">已存在</span>
<span v-else class="cell-subtle">已导入草稿</span>
</td>
</tr>
</tbody>
</table>
</template>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
@@ -703,10 +879,21 @@ onMounted(loadConfigs)
gap: 8px;
}
.lookup-grid,
.lookup-summary {
display: grid;
gap: 12px;
}
.hint-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lookup-grid,
.lookup-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hint-item {
display: grid;
gap: 4px;
@@ -733,6 +920,31 @@ onMounted(loadConfigs)
gap: 6px;
}
.lookup-actions {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.lookup-summary {
margin-top: 16px;
}
.lookup-summary-item {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.14);
}
.lookup-summary-item strong {
color: #1d3555;
}
.meta-label {
color: #64748b;
font-size: 12px;
@@ -932,6 +1144,7 @@ onMounted(loadConfigs)
@media (max-width: 980px) {
.panel-header,
.section-title-row,
.lookup-actions,
.binding-actions,
.binding-header {
flex-direction: column;
@@ -939,6 +1152,8 @@ onMounted(loadConfigs)
}
.hint-grid,
.lookup-grid,
.lookup-summary,
.binding-grid {
grid-template-columns: 1fr;
}