Merge branch 'main' into dev
This commit is contained in:
@@ -2,7 +2,6 @@ import { Router } from 'express'
|
||||
|
||||
import authRouter from './admin/auth.js'
|
||||
import auditLogsRouter from './admin/audit-logs.js'
|
||||
import cloudtentaclesRecordsRouter from './admin/cloudtentacles-records.js'
|
||||
import dashboardRouter from './admin/dashboard.js'
|
||||
import devMockRouter from './admin/dev-mock.js'
|
||||
import financeRouter from './admin/finance.js'
|
||||
@@ -35,7 +34,6 @@ router.use(ordersRouter)
|
||||
router.use(notificationsRouter)
|
||||
router.use(realtimeRouter)
|
||||
router.use(tasksRouter)
|
||||
router.use(cloudtentaclesRecordsRouter)
|
||||
router.use(workerPlatformRouter)
|
||||
router.use(devMockRouter)
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Router } from 'express'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import {
|
||||
listAdminCloudtentaclesDeliveryRecords,
|
||||
listAdminCloudtentaclesRecordSources,
|
||||
} from '../../services/admin/platform-config/cloudtentacles/index.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||
import type { AdminCloudtentaclesDeliveryRecordQueryRouteBody } from '../../types/admin/route-inputs.js'
|
||||
|
||||
const router = Router()
|
||||
const RECORD_IMAGE_CACHE_DIR = path.join(
|
||||
PROJECT_ROOT,
|
||||
'data',
|
||||
'cache',
|
||||
'cloudtentacles-record-images',
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-records/sources',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminCloudtentaclesRecordSources(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 cloudtentacles 账号列表失败',
|
||||
scope: '[admin/cloudtentacles-records/sources]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles-records',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listAdminCloudtentaclesDeliveryRecords(
|
||||
req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: 'cloudtentacles 发货记录查询成功',
|
||||
errorMessage: 'cloudtentacles 发货记录查询失败',
|
||||
scope: '[admin/cloudtentacles-records]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-records/image',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const imageUrl = normalizeAllowedImageUrl(req.query.url)
|
||||
const cachedImage = await getCachedRecordImage(imageUrl)
|
||||
res.setHeader('Content-Type', cachedImage.contentType)
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
res.send(cachedImage.buffer)
|
||||
} catch (error) {
|
||||
sendRouteError(
|
||||
res,
|
||||
error,
|
||||
'读取 cloudtentacles 商品图片失败',
|
||||
'[admin/cloudtentacles-records/image]',
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
function normalizeAllowedImageUrl(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) {
|
||||
throw createHttpError('缺少商品图片地址', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_image_url_required',
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(raw)
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') {
|
||||
throw createHttpError('商品图片地址不在允许范围内', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_image_url_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getCachedRecordImage(imageUrl: string) {
|
||||
const cacheMeta = buildRecordImageCacheMeta(imageUrl)
|
||||
const cached = await readCachedRecordImage(cacheMeta)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const downloaded = await downloadRecordImage(imageUrl)
|
||||
const filePath = cacheMeta.filePathForContentType(downloaded.contentType)
|
||||
await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true })
|
||||
await fs.writeFile(filePath, downloaded.buffer)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
async function readCachedRecordImage(cacheMeta: ReturnType<typeof buildRecordImageCacheMeta>) {
|
||||
for (const candidate of cacheMeta.candidateFilePaths) {
|
||||
try {
|
||||
const buffer = await fs.readFile(candidate.filePath)
|
||||
return {
|
||||
buffer,
|
||||
contentType: candidate.contentType,
|
||||
}
|
||||
} catch (error) {
|
||||
const code = String((error as NodeJS.ErrnoException).code || '')
|
||||
if (code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function downloadRecordImage(imageUrl: string) {
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
referer: 'https://gp.playinjoy.com/',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_record_image_fetch_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw createHttpError('商品图片响应格式无效', {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_record_image_invalid_content_type',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: normalizeImageContentType(contentType),
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecordImageCacheMeta(imageUrl: string) {
|
||||
const digest = crypto.createHash('sha256').update(imageUrl).digest('hex')
|
||||
const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
return {
|
||||
candidateFilePaths: candidateTypes.map((contentType) => ({
|
||||
contentType,
|
||||
filePath: path.join(
|
||||
RECORD_IMAGE_CACHE_DIR,
|
||||
`${digest}.${extensionForContentType(contentType)}`,
|
||||
),
|
||||
})),
|
||||
filePathForContentType(contentType: string) {
|
||||
return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageContentType(contentType: string) {
|
||||
return (
|
||||
String(contentType || 'image/jpeg')
|
||||
.split(';')[0]
|
||||
?.trim()
|
||||
.toLowerCase() || 'image/jpeg'
|
||||
)
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType: string) {
|
||||
const normalized = normalizeImageContentType(contentType)
|
||||
if (normalized === 'image/png') return 'png'
|
||||
if (normalized === 'image/webp') return 'webp'
|
||||
if (normalized === 'image/gif') return 'gif'
|
||||
return 'jpg'
|
||||
}
|
||||
@@ -3,9 +3,7 @@ import { Router } from 'express'
|
||||
import {
|
||||
buildOpen91QueryMock,
|
||||
createAffiliateDashMockClaim,
|
||||
createFeifeiMockClaim,
|
||||
createKuaishouIndustryVoucherMockData,
|
||||
createLewanMockClaim,
|
||||
getDevMockStatus,
|
||||
isDevMockEnabled,
|
||||
} from '../../services/dev-mock/dev-mock-service.js'
|
||||
@@ -41,44 +39,6 @@ router.get(
|
||||
|
||||
router.use('/dev-mock', requireAdminRoles(['admin', 'operator']), requireDevMockEnabled())
|
||||
|
||||
router.post(
|
||||
'/dev-mock/lewan',
|
||||
createJsonHandler((req) => createLewanMockClaim(req.body || {}), {
|
||||
successMessage: 'lewan mock 已生成',
|
||||
errorMessage: '生成 lewan mock 失败',
|
||||
scope: '[admin/dev-mock/lewan]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'dev_mock_create_lewan',
|
||||
targetType: 'dev_mock',
|
||||
targetId: String((data as { orderNo?: string })?.orderNo || ''),
|
||||
data: {
|
||||
platform: 'lewan',
|
||||
orderNo: (data as { orderNo?: string })?.orderNo,
|
||||
step: (data as { step?: string })?.step,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/dev-mock/feifei',
|
||||
createJsonHandler((req) => createFeifeiMockClaim(req.body || {}), {
|
||||
successMessage: 'feifei mock 已生成',
|
||||
errorMessage: '生成 feifei mock 失败',
|
||||
scope: '[admin/dev-mock/feifei]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'dev_mock_create_feifei',
|
||||
targetType: 'dev_mock',
|
||||
targetId: String((data as { orderNo?: string })?.orderNo || ''),
|
||||
data: {
|
||||
platform: 'feifei',
|
||||
orderNo: (data as { orderNo?: string })?.orderNo,
|
||||
step: (data as { step?: string })?.step,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/dev-mock/affiliate-dash',
|
||||
createJsonHandler((req) => createAffiliateDashMockClaim(req.body || {}), {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js'
|
||||
import { resendAdminOrderKuaishouIndustryVoucherCodes } from '../../services/admin/write/kuaishou-cloud-actions.js'
|
||||
import { resendAdminOrderKuaishouIndustryVoucherCodes } from '../../services/admin/write/kuaishou-industry-actions.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/admin/route-inputs.js'
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { requireAdminRoles } from './session.js'
|
||||
import cloudtentaclesRouter from './platform-config/cloudtentacles.js'
|
||||
import kuaishouIndustryRouter from './platform-config/kuaishou-industry.js'
|
||||
import kuaishouFeifeiRouter from './platform-config/kuaishou-feifei.js'
|
||||
import ninetyoneRouter from './platform-config/ninetyone.js'
|
||||
import notificationsRouter from './platform-config/notifications.js'
|
||||
import fulfillmentRoutingRouter from './platform-config/fulfillment-routing.js'
|
||||
@@ -15,10 +13,8 @@ const router = Router()
|
||||
router.use('/platform-config', kuaishouIndustryRouter)
|
||||
router.use('/platform-config', requireAdminRoles(['admin']))
|
||||
router.use('/platform-config', notificationsRouter)
|
||||
router.use('/platform-config', kuaishouFeifeiRouter)
|
||||
router.use('/platform-config', ninetyoneRouter)
|
||||
router.use('/platform-config', fulfillmentRoutingRouter)
|
||||
router.use('/platform-config', cloudtentaclesRouter)
|
||||
router.use('/platform-config', affiliateDashRouter)
|
||||
router.use('/platform-config', pddRouter)
|
||||
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
appointAdminCloudtentaclesVirtualNumber,
|
||||
backAdminCloudtentaclesVirtualNumber,
|
||||
buyAdminCloudtentaclesSku,
|
||||
deleteAdminCloudtentaclesSource,
|
||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||
generateAdminCloudtentaclesLoginCode,
|
||||
getAdminCloudtentaclesOverrideRules,
|
||||
getAdminCloudtentaclesAsset,
|
||||
getAdminCloudtentaclesBindUrl,
|
||||
getAdminCloudtentaclesCategories,
|
||||
getAdminCloudtentaclesKnapsack,
|
||||
getAdminCloudtentaclesSkuList,
|
||||
listAdminCloudtentaclesSources,
|
||||
listAdminCloudtentaclesVirtualNumbers,
|
||||
runAdminCloudtentaclesFullFlow,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
testAdminCloudtentaclesLogin,
|
||||
updateAdminCloudtentaclesOverrideRules,
|
||||
updateAdminCloudtentaclesSourceConfig,
|
||||
useAdminCloudtentaclesSku,
|
||||
validateAdminCloudtentaclesSession,
|
||||
verifyAdminCloudtentaclesLoginCode,
|
||||
} from '../../../services/admin/platform-config/cloudtentacles/index.js'
|
||||
import type {
|
||||
AdminCloudtentaclesCatalogQueryRouteBody,
|
||||
AdminCloudtentaclesFullFlowRouteBody,
|
||||
AdminCloudtentaclesOverrideRuleConfigRouteBody,
|
||||
AdminCloudtentaclesSendSmsCodeRouteBody,
|
||||
AdminCloudtentaclesSkuBuyRouteBody,
|
||||
AdminCloudtentaclesSkuUseRouteBody,
|
||||
AdminCloudtentaclesSourceConfigRouteBody,
|
||||
AdminCloudtentaclesTestLoginRouteBody,
|
||||
AdminCloudtentaclesValidateSessionRouteBody,
|
||||
AdminCloudtentaclesVirtualNumberRouteBody,
|
||||
} from '../../../types/admin/route-inputs.js'
|
||||
import { createJsonHandler } from '../session.js'
|
||||
import type { JsonRecord } from '../../../types/json.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-source',
|
||||
createJsonHandler(() => listAdminCloudtentaclesSources(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 cloudtentacles 履约平台配置失败',
|
||||
scope: '[admin/platform-config/cloudtentacles-source]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/cloudtentacles-source/:sourceKey',
|
||||
createJsonHandler(
|
||||
(req) => deleteAdminCloudtentaclesSource(String(req.params.sourceKey || '').trim()),
|
||||
{
|
||||
successMessage: 'cloudtentacles 履约平台配置已删除',
|
||||
errorMessage: '删除 cloudtentacles 履约平台配置失败',
|
||||
scope: '[admin/platform-config/cloudtentacles-source/:sourceKey]',
|
||||
audit: (req) => ({
|
||||
action: 'platform_cloudtentacles_source_deleted',
|
||||
targetType: 'platform_config',
|
||||
targetId: String(req.params.sourceKey || '').trim(),
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles-source',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminCloudtentaclesSourceConfig(req.body as AdminCloudtentaclesSourceConfigRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 履约平台配置已保存',
|
||||
errorMessage: '保存 cloudtentacles 履约平台配置失败',
|
||||
scope: '[admin/platform-config/cloudtentacles-source]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
return {
|
||||
action: 'platform_cloudtentacles_source_updated',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'cloudtentacles_source',
|
||||
data: {
|
||||
filePath: String(result.filePath || '').trim(),
|
||||
username: String(result.source?.username || '').trim(),
|
||||
enabled: Boolean(result.source?.enabled),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles/override-rules',
|
||||
createJsonHandler(() => getAdminCloudtentaclesOverrideRules(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 cloudtentacles 覆盖规则失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/override-rules]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/override-rules',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminCloudtentaclesOverrideRules(
|
||||
req.body as AdminCloudtentaclesOverrideRuleConfigRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: 'cloudtentacles 覆盖规则已保存',
|
||||
errorMessage: '保存 cloudtentacles 覆盖规则失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/override-rules]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
return {
|
||||
action: 'platform_cloudtentacles_override_rules_updated',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'cloudtentacles_override_rules',
|
||||
data: {
|
||||
filePath: String(result.filePath || '').trim(),
|
||||
enabled: result.enabled !== false,
|
||||
ruleCount: Array.isArray(result.rules) ? result.rules.length : 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/send-sms-code',
|
||||
createJsonHandler(
|
||||
(req) => sendAdminCloudtentaclesSmsCode(req.body as AdminCloudtentaclesSendSmsCodeRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 短信验证码已发送',
|
||||
errorMessage: 'cloudtentacles 发送短信验证码失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/send-sms-code]',
|
||||
audit: (req, data) => {
|
||||
const body = req.body as AdminCloudtentaclesSendSmsCodeRouteBody
|
||||
const result = data as JsonRecord
|
||||
return {
|
||||
action: 'platform_cloudtentacles_send_sms_code',
|
||||
targetType: 'platform_config',
|
||||
targetId: String(result.username || body.username || '').trim() || 'cloudtentacles',
|
||||
data: {
|
||||
baseUrl: result.baseUrl || String(body.baseUrl || '').trim(),
|
||||
phoneMasked: result.phoneMasked || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/test-login',
|
||||
createJsonHandler(
|
||||
(req) => testAdminCloudtentaclesLogin(req.body as AdminCloudtentaclesTestLoginRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 登录测试成功',
|
||||
errorMessage: 'cloudtentacles 登录测试失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/test-login]',
|
||||
audit: (req, data) => {
|
||||
const body = req.body as AdminCloudtentaclesTestLoginRouteBody
|
||||
const result = data as JsonRecord
|
||||
return {
|
||||
action: 'platform_cloudtentacles_test_login',
|
||||
targetType: 'platform_config',
|
||||
targetId: String(result.username || body.username || '').trim() || 'cloudtentacles',
|
||||
data: {
|
||||
baseUrl: result.baseUrl || String(body.baseUrl || '').trim(),
|
||||
permissionCount: Number(result.session?.permissionCount || 0),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/validate-session',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
validateAdminCloudtentaclesSession(req.body as AdminCloudtentaclesValidateSessionRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 会话校验成功',
|
||||
errorMessage: 'cloudtentacles 会话校验失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/validate-session]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
return {
|
||||
action: 'platform_cloudtentacles_validate_session',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'cloudtentacles_session',
|
||||
data: {
|
||||
baseUrl: String(result.baseUrl || '').trim(),
|
||||
permissionCount: Number(result.session?.permissionCount || 0),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/asset',
|
||||
createJsonHandler(
|
||||
(req) => getAdminCloudtentaclesAsset(req.body as AdminCloudtentaclesCatalogQueryRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 余额查询成功',
|
||||
errorMessage: 'cloudtentacles 余额查询失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/asset]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/categories',
|
||||
createJsonHandler(
|
||||
(req) => getAdminCloudtentaclesCategories(req.body as AdminCloudtentaclesCatalogQueryRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 分类查询成功',
|
||||
errorMessage: 'cloudtentacles 分类查询失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/categories]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/sku/list',
|
||||
createJsonHandler(
|
||||
(req) => getAdminCloudtentaclesSkuList(req.body as AdminCloudtentaclesCatalogQueryRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles SKU 列表查询成功',
|
||||
errorMessage: 'cloudtentacles SKU 列表查询失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/sku/list]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/sku/buy',
|
||||
createJsonHandler(
|
||||
(req) => buyAdminCloudtentaclesSku(req.body as AdminCloudtentaclesSkuBuyRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles SKU 购买成功',
|
||||
errorMessage: 'cloudtentacles SKU 购买失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/sku/buy]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/sku/use',
|
||||
createJsonHandler(
|
||||
(req) => useAdminCloudtentaclesSku(req.body as AdminCloudtentaclesSkuUseRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 发货成功',
|
||||
errorMessage: 'cloudtentacles 发货失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/sku/use]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/knapsack',
|
||||
createJsonHandler(
|
||||
(req) => getAdminCloudtentaclesKnapsack(req.body as AdminCloudtentaclesCatalogQueryRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 背包查询成功',
|
||||
errorMessage: 'cloudtentacles 背包查询失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/knapsack]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/list',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listAdminCloudtentaclesVirtualNumbers(req.body as AdminCloudtentaclesVirtualNumberRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 虚拟号列表查询成功',
|
||||
errorMessage: 'cloudtentacles 虚拟号列表查询失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/list]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/appoint',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
appointAdminCloudtentaclesVirtualNumber(
|
||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: 'cloudtentacles 虚拟号申请成功',
|
||||
errorMessage: 'cloudtentacles 虚拟号申请失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/appoint]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/generate-login-code',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
generateAdminCloudtentaclesLoginCode(req.body as AdminCloudtentaclesVirtualNumberRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 登录码生成成功',
|
||||
errorMessage: 'cloudtentacles 登录码生成失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/generate-login-code]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/fetch-code',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
fetchAdminCloudtentaclesVirtualNumberCode(
|
||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: 'cloudtentacles 验证码获取成功',
|
||||
errorMessage: 'cloudtentacles 验证码获取失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/fetch-code]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/verify-code',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
verifyAdminCloudtentaclesLoginCode(req.body as AdminCloudtentaclesVirtualNumberRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 登录码校验成功',
|
||||
errorMessage: 'cloudtentacles 登录码校验失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/verify-code]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/bind-url',
|
||||
createJsonHandler(
|
||||
(req) => getAdminCloudtentaclesBindUrl(req.body as AdminCloudtentaclesVirtualNumberRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 兑换链接获取成功',
|
||||
errorMessage: 'cloudtentacles 兑换链接获取失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/bind-url]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/vn/back',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
backAdminCloudtentaclesVirtualNumber(req.body as AdminCloudtentaclesVirtualNumberRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 号码退还成功',
|
||||
errorMessage: 'cloudtentacles 号码退还失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/vn/back]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles/debug/full-flow',
|
||||
createJsonHandler(
|
||||
(req) => runAdminCloudtentaclesFullFlow(req.body as AdminCloudtentaclesFullFlowRouteBody),
|
||||
{
|
||||
successMessage: 'cloudtentacles 完整调试流程执行成功',
|
||||
errorMessage: 'cloudtentacles 完整调试流程执行失败',
|
||||
scope: '[admin/platform-config/cloudtentacles/debug/full-flow]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createAdminKuaishouFeifeiTestOrder,
|
||||
getAdminKuaishouFeifeiConfig,
|
||||
listAdminKuaishouFeifeiProducts,
|
||||
matchAdminKuaishouFeifeiProduct,
|
||||
queryAdminKuaishouFeifeiTestOrder,
|
||||
syncAdminKuaishouFeifeiProductRules,
|
||||
updateAdminKuaishouFeifeiConfig,
|
||||
} from '../../../services/admin/platform-config/kuaishou-feifei-service.js'
|
||||
import { createJsonHandler } from '../session.js'
|
||||
import type { JsonRecord } from '../../../types/json.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get(
|
||||
'/kuaishou-feifei',
|
||||
createJsonHandler(() => getAdminKuaishouFeifeiConfig(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 kuaishou-feifei 配置失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-feifei',
|
||||
createJsonHandler((req) => updateAdminKuaishouFeifeiConfig(req.body as JsonRecord), {
|
||||
successMessage: 'kuaishou-feifei 配置已保存',
|
||||
errorMessage: '保存 kuaishou-feifei 配置失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
const source = result.source as JsonRecord | undefined
|
||||
return {
|
||||
action: 'platform_kuaishou_feifei_config_updated',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'kuaishou_feifei',
|
||||
data: {
|
||||
filePath: String(result.filePath || '').trim(),
|
||||
enabled: source?.enabled !== false,
|
||||
ruleCount: Array.isArray(source?.productRules) ? source.productRules.length : 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-feifei/match',
|
||||
createJsonHandler((req) => matchAdminKuaishouFeifeiProduct(req.body as JsonRecord), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '匹配 kuaishou-feifei 商品失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei/match]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-feifei/products',
|
||||
createJsonHandler((req) => listAdminKuaishouFeifeiProducts(req.body as JsonRecord), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '查询 kuaishou-feifei 商品失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei/products]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-feifei/sync-products',
|
||||
createJsonHandler((req) => syncAdminKuaishouFeifeiProductRules(req.body as JsonRecord), {
|
||||
successMessage: 'kuaishou-feifei 商品映射已同步',
|
||||
errorMessage: '同步 kuaishou-feifei 商品映射失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei/sync-products]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
const sync = result.sync as JsonRecord | undefined
|
||||
|
||||
return {
|
||||
action: 'platform_kuaishou_feifei_products_synced',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'kuaishou_feifei',
|
||||
data: {
|
||||
productCount: Number(sync?.productCount || 0) || 0,
|
||||
ruleCount: Number(sync?.ruleCount || 0) || 0,
|
||||
status: String(sync?.status || '').trim(),
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-feifei/test-order',
|
||||
createJsonHandler((req) => createAdminKuaishouFeifeiTestOrder(req.body as JsonRecord), {
|
||||
successMessage: 'kuaishou-feifei 测试单已创建',
|
||||
errorMessage: '创建 kuaishou-feifei 测试单失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei/test-order]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
|
||||
return {
|
||||
action: 'platform_kuaishou_feifei_test_order_created',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'kuaishou_feifei',
|
||||
data: {
|
||||
orderNo: String(result.orderNo || '').trim(),
|
||||
platformOrderNo: String(result.platformOrderNo || '').trim(),
|
||||
productCode: String(result.productCode || '').trim(),
|
||||
rechargeStatus: Number(result.rechargeStatus || 0) || 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-feifei/query-order',
|
||||
createJsonHandler((req) => queryAdminKuaishouFeifeiTestOrder(req.body as JsonRecord), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '查询 kuaishou-feifei 订单失败',
|
||||
scope: '[admin/platform-config/kuaishou-feifei/query-order]',
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
deletePddStore,
|
||||
getPddStoreById,
|
||||
updatePddStoreCheckStatus,
|
||||
generatePddStoreSyncToken,
|
||||
} from '../../../services/platforms/pdd/pdd-source-config-service.js'
|
||||
import { notifyPddCookieExpired } from '../../../services/notification/domain-notifications.js'
|
||||
import {
|
||||
@@ -102,6 +103,35 @@ router.delete(
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /platform-config/pdd/stores/:shopId/sync-token
|
||||
* 为指定拼多多店铺生成或重置浏览器插件配对密钥。
|
||||
*/
|
||||
router.post(
|
||||
'/pdd/stores/:shopId/sync-token',
|
||||
createJsonHandler(
|
||||
async (req) => {
|
||||
const syncToken = await generatePddStoreSyncToken(req.params.shopId)
|
||||
const store = getPddStoreById(req.params.shopId)
|
||||
return {
|
||||
shopId: req.params.shopId,
|
||||
shopName: store?.shopName || req.params.shopId,
|
||||
syncToken,
|
||||
}
|
||||
},
|
||||
{
|
||||
successMessage: '店铺插件配对密钥已生成',
|
||||
errorMessage: '生成插件配对密钥失败',
|
||||
scope: '[admin/platform-config/pdd/stores/:shopId/sync-token]',
|
||||
audit: (req) => ({
|
||||
action: 'platform_pdd_store_token_generated',
|
||||
targetType: 'platform_store',
|
||||
targetId: String(req.params.shopId || ''),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /platform-config/pdd/test-cookie
|
||||
* Test Cookie validity for a given cookie string or an existing shop.
|
||||
|
||||
@@ -2,23 +2,28 @@ import type { Request } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js'
|
||||
import { closeAdminTask } from '../../services/admin/write/task-actions.js'
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
retryAdminTask,
|
||||
} from '../../services/admin/write/task-actions.js'
|
||||
import {
|
||||
consumeAdminTaskKuaishouIndustryVoucher,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
resendAdminTaskKuaishouIndustryVoucherCode,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from '../../services/admin/write/kuaishou-cloud-actions.js'
|
||||
} from '../../services/admin/write/kuaishou-industry-actions.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import type {
|
||||
AdminTaskKuaishouIndustryConsumeRouteBody,
|
||||
AdminTaskManualDispatchRouteBody,
|
||||
AdminTaskRouteParams,
|
||||
AdminTaskRouteQuery,
|
||||
} from '../../types/admin/route-inputs.js'
|
||||
import type { AdminTaskActionResponse } from '../../types/admin/write-models.js'
|
||||
import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from '../../types/admin/write-models.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -49,6 +54,31 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/retry',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => retryAdminTask(getTaskId(req), req.adminSession || null), {
|
||||
successMessage: '任务已重试',
|
||||
errorMessage: '重试任务失败',
|
||||
scope: '[admin/tasks/:taskId/retry]',
|
||||
audit: (req, data) => buildTaskAudit('task_retry', req, data),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/regenerate-claim-link',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) => regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: '领取链接已重新生成',
|
||||
errorMessage: '重新生成领取链接失败',
|
||||
scope: '[admin/tasks/:taskId/regenerate-claim-link]',
|
||||
audit: (req, data) => buildTaskAudit('task_regenerate_claim_link', req, data),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/close',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
@@ -61,69 +91,42 @@ router.post(
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/kuaishou-cloud/prepare',
|
||||
'/tasks/:taskId/mark-manual-review',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req), req.adminSession || null), {
|
||||
successMessage: '任务已转人工处理',
|
||||
errorMessage: '标记人工处理失败',
|
||||
scope: '[admin/tasks/:taskId/mark-manual-review]',
|
||||
audit: (req, data) => buildTaskAudit('task_mark_manual_review', req, data),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/complete-manual-dispatch',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||
(req) =>
|
||||
completeAdminTaskManualDispatch(
|
||||
getTaskId(req),
|
||||
req.body as AdminTaskManualDispatchRouteBody,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '绑定资源已准备完成',
|
||||
errorMessage: '准备绑定资源失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_prepare', req, data),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/kuaishou-cloud/refresh-role-info',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: '角色信息已刷新',
|
||||
errorMessage: '刷新角色信息失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/kuaishou-cloud/rebind-role',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
// 旧号已回收时会自动跳过退号并新取号出链
|
||||
successMessage: '换绑完成:已生成新的绑定链接(原领取链接可继续使用)',
|
||||
errorMessage: '换绑角色失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/kuaishou-cloud/dispatch',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) => dispatchAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: '已完成绑定确认并发货',
|
||||
errorMessage: '执行发货失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]',
|
||||
successMessage: '人工履约结果已回写',
|
||||
errorMessage: '回写人工履约结果失败',
|
||||
scope: '[admin/tasks/:taskId/complete-manual-dispatch]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse
|
||||
|
||||
const result = data as AdminTaskManualDispatchResponse
|
||||
return {
|
||||
action: 'task_kuaishou_cloud_dispatch',
|
||||
action: 'task_complete_manual_dispatch',
|
||||
targetType: 'task',
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
outcome: result.outcome,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
resultCode: result.task.resultCode,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -131,21 +134,6 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/kuaishou-cloud/return-number',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: '号码已退还',
|
||||
errorMessage: '退还号码失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_return_number', req, data),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/tasks/:taskId/kuaishou-industry/resend-code',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import {
|
||||
confirmKuaishouCloudClaimRole,
|
||||
getKuaishouCloudClaimDetail,
|
||||
rebindKuaishouCloudClaimRole,
|
||||
redeemKuaishouCloudClaim,
|
||||
submitAffiliateDashClaim,
|
||||
submitClaimUid,
|
||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||
@@ -24,12 +21,6 @@ const claimWriteRateLimit = createRateLimitMiddleware({
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
const claimRebindRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claim:rebind',
|
||||
windowMs: 60_000,
|
||||
max: 3,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/:token',
|
||||
@@ -60,36 +51,6 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/confirm-role',
|
||||
claimWriteRateLimit,
|
||||
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: '角色已确认',
|
||||
errorMessage: '确认角色失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/confirm-role]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/rebind-role',
|
||||
claimRebindRateLimit,
|
||||
createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
errorMessage: '换绑角色失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/rebind-role]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/redeem',
|
||||
claimWriteRateLimit,
|
||||
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||
successMessage: '兑换请求已提交',
|
||||
errorMessage: '兑换失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/redeem]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||
import { handleKuaishouFeifeiNotify } from '../services/platforms/kuaishou-feifei/notify-service.js'
|
||||
import { createRequestId, logIntegration } from '../utils/logger.js'
|
||||
import { sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const notifyRateLimit = createRateLimitMiddleware({
|
||||
scope: 'kuaishouFeifeiNotify',
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
})
|
||||
|
||||
router.post('/notify', notifyRateLimit, async (req, res) => {
|
||||
const requestId = createRequestId('ffn')
|
||||
const startedAt = Date.now()
|
||||
|
||||
logIntegration('[kuaishou-feifei/notify]', '收到 kuaishou-feifei 订单通知', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
ip: req.ip,
|
||||
headers: {
|
||||
'content-type': req.headers['content-type'],
|
||||
'x-app-key': req.headers['x-app-key'],
|
||||
'x-timestamp': req.headers['x-timestamp'],
|
||||
'x-sign': req.headers['x-sign'],
|
||||
},
|
||||
body: req.body,
|
||||
rawBody: req.rawBody,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await handleKuaishouFeifeiNotify({
|
||||
body: req.body,
|
||||
headers: req.headers,
|
||||
rawBody: req.rawBody,
|
||||
})
|
||||
logIntegration('[kuaishou-feifei/notify]', '订单通知处理完成', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
result,
|
||||
})
|
||||
res.status(200).json({ code: 0 })
|
||||
} catch (error) {
|
||||
logIntegration(
|
||||
'[kuaishou-feifei/notify]',
|
||||
'订单通知处理失败',
|
||||
{
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
},
|
||||
{ level: 'error' },
|
||||
)
|
||||
sendRouteError(res, error, 'kuaishou-feifei 通知处理失败', '[kuaishou-feifei/notify]')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import express from 'express'
|
||||
import http from 'node:http'
|
||||
|
||||
import pddExtensionRouter from './pdd-extension.js'
|
||||
import {
|
||||
getOrCreatePddRsaKeyPair,
|
||||
testEncryptPddPayload,
|
||||
} from '../services/platforms/pdd/pdd-crypto-service.js'
|
||||
import {
|
||||
generatePddStoreSyncToken,
|
||||
getPddStoreById,
|
||||
savePddSourcesConfig,
|
||||
getPddSourcesConfig,
|
||||
} from '../services/platforms/pdd/pdd-source-config-service.js'
|
||||
import { PddClient } from '../services/platforms/pdd/pdd-client.js'
|
||||
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
function makeTestApp() {
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use('/api/v1/pdd/extension', pddExtensionRouter)
|
||||
return app
|
||||
}
|
||||
|
||||
test('pdd-extension router: GET /public-key and POST /sync-cookie full flow', async (t) => {
|
||||
let dbAvailable = false
|
||||
try {
|
||||
const check = await query('SELECT 1').catch(() => null)
|
||||
dbAvailable = Boolean(check)
|
||||
} catch {
|
||||
dbAvailable = false
|
||||
}
|
||||
|
||||
if (!dbAvailable) {
|
||||
t.skip('Skipping integration test: PostgreSQL database is not reachable outside container')
|
||||
return
|
||||
}
|
||||
|
||||
const app = makeTestApp()
|
||||
const server = http.createServer(app)
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve))
|
||||
const addr = server.address()
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0
|
||||
const baseUrl = `http://127.0.0.1:${port}/api/v1/pdd/extension`
|
||||
|
||||
// Prepare a test store in config
|
||||
const originalConfig = getPddSourcesConfig()
|
||||
const testShopId = 'test_shop_router_01'
|
||||
const mockConfig = {
|
||||
...originalConfig,
|
||||
stores: [
|
||||
{
|
||||
shopId: testShopId,
|
||||
shopName: '测试路由店铺',
|
||||
cookie: 'old_cookie=123;',
|
||||
cookieValid: false,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
await savePddSourcesConfig(mockConfig)
|
||||
|
||||
// Generate sync token
|
||||
const token = await generatePddStoreSyncToken(testShopId)
|
||||
|
||||
// Mock PddClient.testCookie
|
||||
const originalTestCookie = PddClient.testCookie
|
||||
PddClient.testCookie = async () => ({
|
||||
valid: true,
|
||||
totalOrders: 88,
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Test GET /public-key
|
||||
const pkResp = await fetch(`${baseUrl}/public-key`)
|
||||
assert.equal(pkResp.status, 200)
|
||||
const pkData = await pkResp.json()
|
||||
assert.equal(pkData.code, 0)
|
||||
assert.ok(pkData.data.publicKeySpki)
|
||||
assert.equal(pkData.data.algorithm, 'RSA-OAEP-256')
|
||||
|
||||
// 2. Test POST /sync-cookie with missing token
|
||||
const kp = getOrCreatePddRsaKeyPair()
|
||||
const payload = {
|
||||
shopId: testShopId,
|
||||
cookie: 'new_fresh_cookie=xyz;',
|
||||
userAgent: 'Mozilla/5.0 Extension Test',
|
||||
timestamp: Date.now(),
|
||||
nonce: 'nonce_test_' + Math.random().toString(36).slice(2),
|
||||
}
|
||||
const envelope = testEncryptPddPayload(payload, kp.publicKeyPem)
|
||||
|
||||
const noTokenResp = await fetch(`${baseUrl}/sync-cookie`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ envelope }),
|
||||
})
|
||||
assert.equal(noTokenResp.status, 401)
|
||||
|
||||
// 3. Test POST /sync-cookie with wrong token
|
||||
const wrongTokenResp = await fetch(`${baseUrl}/sync-cookie`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shop-Token': 'wrong-token-abc',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelope: testEncryptPddPayload(
|
||||
{ ...payload, nonce: 'nonce_wrong_' + Math.random().toString(36).slice(2) },
|
||||
kp.publicKeyPem,
|
||||
),
|
||||
}),
|
||||
})
|
||||
assert.equal(wrongTokenResp.status, 403)
|
||||
|
||||
// 4. Test POST /sync-cookie with valid token and fresh nonce
|
||||
const freshPayload = {
|
||||
...payload,
|
||||
nonce: 'nonce_fresh_' + Math.random().toString(36).slice(2),
|
||||
}
|
||||
const freshEnvelope = testEncryptPddPayload(freshPayload, kp.publicKeyPem)
|
||||
const validResp = await fetch(`${baseUrl}/sync-cookie`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shop-Token': token,
|
||||
},
|
||||
body: JSON.stringify({ envelope: freshEnvelope }),
|
||||
})
|
||||
assert.equal(validResp.status, 200)
|
||||
const validData = await validResp.json()
|
||||
assert.equal(validData.code, 0)
|
||||
assert.equal(validData.data.shopId, testShopId)
|
||||
assert.equal(validData.data.valid, true)
|
||||
assert.equal(validData.data.totalOrders, 88)
|
||||
|
||||
// Verify store was updated in memory/config
|
||||
const updatedStore = getPddStoreById(testShopId)
|
||||
assert.ok(updatedStore)
|
||||
assert.equal(updatedStore.cookie, 'new_fresh_cookie=xyz;')
|
||||
assert.equal(updatedStore.cookieValid, true)
|
||||
assert.ok(updatedStore.lastExtensionSyncAt)
|
||||
} finally {
|
||||
PddClient.testCookie = originalTestCookie
|
||||
await savePddSourcesConfig(originalConfig)
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Pinduoduo Extension Public API Routes
|
||||
* 拼多多浏览器插件端到端加密同步接口
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import {
|
||||
decryptPddExtensionPayload,
|
||||
getPddExtensionPublicKeyInfo,
|
||||
type PddEncryptedEnvelope,
|
||||
} from '../services/platforms/pdd/pdd-crypto-service.js'
|
||||
import {
|
||||
getPddStoreById,
|
||||
updatePddStoreCookieFromExtension,
|
||||
verifyPddStoreSyncToken,
|
||||
} from '../services/platforms/pdd/pdd-source-config-service.js'
|
||||
import { PddClient } from '../services/platforms/pdd/pdd-client.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { logInfo, logWarn } from '../utils/logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 浏览器扩展跨域支持(支持 chrome-extension:// 等各种来源)
|
||||
router.use((req, res, next) => {
|
||||
const origin = req.headers.origin
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
} else {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Shop-Token')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(204)
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/pdd/extension/public-key
|
||||
* 获取服务端 RSA-OAEP 2048 公钥信息(供浏览器插件加密使用)
|
||||
*/
|
||||
router.get('/public-key', (_req, res) => {
|
||||
const info = getPddExtensionPublicKeyInfo()
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: info,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/v1/pdd/extension/sync-cookie
|
||||
* 浏览器插件上传端到端加密的最新 Cookie
|
||||
*/
|
||||
router.post('/sync-cookie', async (req, res, next) => {
|
||||
try {
|
||||
const envelope = (req.body?.envelope || req.body) as PddEncryptedEnvelope
|
||||
|
||||
// 1. 端到端安全解密
|
||||
const decrypted = decryptPddExtensionPayload(envelope)
|
||||
|
||||
const clientToken = String(
|
||||
req.headers['x-shop-token'] ||
|
||||
req.body?.token ||
|
||||
req.query?.token ||
|
||||
decrypted.syncToken ||
|
||||
'',
|
||||
).trim()
|
||||
|
||||
if (!clientToken) {
|
||||
throw createHttpError('未提供店铺专属配对密钥 (X-Shop-Token header 或 token 字段)', {
|
||||
statusCode: 401,
|
||||
errorCode: 'missing_shop_token',
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 校验店铺配对密钥
|
||||
const isTokenValid = verifyPddStoreSyncToken(decrypted.shopId, clientToken)
|
||||
if (!isTokenValid) {
|
||||
logWarn('[pdd-extension]', `插件尝试为店铺 ${decrypted.shopId} 上传 Cookie,但配对密钥无效`, {
|
||||
shopId: decrypted.shopId,
|
||||
ip: req.ip,
|
||||
})
|
||||
throw createHttpError('店铺配对密钥无效或已重置,请重新在后台复制配置', {
|
||||
statusCode: 403,
|
||||
errorCode: 'invalid_shop_token',
|
||||
})
|
||||
}
|
||||
|
||||
const store = getPddStoreById(decrypted.shopId)
|
||||
if (!store) {
|
||||
throw createHttpError(`店铺不存在: ${decrypted.shopId}`, {
|
||||
statusCode: 404,
|
||||
errorCode: 'store_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 诊断与实测 Cookie 有效性(轻量探测)
|
||||
const cookieKeys = decrypted.cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim().split('=')[0]?.trim())
|
||||
.filter((k): k is string => Boolean(k))
|
||||
const hasPassId = cookieKeys.includes('PASS_ID')
|
||||
const hasRckk = cookieKeys.includes('rckk')
|
||||
|
||||
logInfo(
|
||||
'[pdd-extension]',
|
||||
`插件上报店铺 [${store.shopName}(${store.shopId})] Cookie,开始验证`,
|
||||
{
|
||||
shopId: store.shopId,
|
||||
cookieLen: decrypted.cookie.length,
|
||||
cookieKeyCount: cookieKeys.length,
|
||||
hasPassId,
|
||||
hasRckk,
|
||||
cookieKeys: cookieKeys.slice(0, 15),
|
||||
},
|
||||
)
|
||||
|
||||
const testResult = await PddClient.testCookie({
|
||||
cookie: decrypted.cookie,
|
||||
userAgent: decrypted.userAgent || store.userAgent,
|
||||
})
|
||||
|
||||
if (!testResult.valid) {
|
||||
// 容错机制:若 Cookie 包含 PASS_ID 且拼多多官方接口触发频控 (40002),说明会话身份有效已通达官方网关
|
||||
// (若未登录或已失效,PDD 会优先返回 43001 会话过期,而非 40002 频控)。
|
||||
// 此时采纳并保存 Cookie,避免客服陷入重复点击导致的频控死循环。
|
||||
if (testResult.rateLimited && hasPassId) {
|
||||
logWarn(
|
||||
'[pdd-extension]',
|
||||
`插件上报店铺 [${store.shopName}(${store.shopId})] Cookie 探测触发频控 (40002),但包含有效 PASS_ID,视为有效并采纳更新`,
|
||||
{
|
||||
shopId: store.shopId,
|
||||
hasPassId,
|
||||
hasRckk,
|
||||
},
|
||||
)
|
||||
|
||||
const updatedStore = await updatePddStoreCookieFromExtension(store.shopId, {
|
||||
cookie: decrypted.cookie,
|
||||
userAgent: decrypted.userAgent,
|
||||
etag: decrypted.etag,
|
||||
})
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message:
|
||||
'Cookie 同步成功!(网关提示操作过于频繁 40002,凭证已保存生效,后台将在静默后自动拉单)',
|
||||
data: {
|
||||
shopId: updatedStore.shopId,
|
||||
shopName: updatedStore.shopName,
|
||||
valid: true,
|
||||
rateLimited: true,
|
||||
lastExtensionSyncAt: updatedStore.lastExtensionSyncAt,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logWarn('[pdd-extension]', `插件上报的 Cookie 实测未通过: ${testResult.message}`, {
|
||||
shopId: store.shopId,
|
||||
hasPassId,
|
||||
hasRckk,
|
||||
cookieLen: decrypted.cookie.length,
|
||||
})
|
||||
|
||||
const isSessionExpired =
|
||||
testResult.message.includes('43001') ||
|
||||
testResult.message.includes('会话已过期') ||
|
||||
!hasPassId
|
||||
|
||||
const userGuidance = !hasPassId
|
||||
? 'Cookie 中未包含拼多多核心会话标识 (PASS_ID)。请在当前浏览器打开并登录 mms.pinduoduo.com 商家后台,再点击插件上传。'
|
||||
: isSessionExpired
|
||||
? '拼多多商家会话已过期或失效 (43001)。请在浏览器刷新 mms.pinduoduo.com 页面重新登录,然后点击插件重新同步。'
|
||||
: `Cookie 校验失败: ${testResult.message}`
|
||||
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: userGuidance,
|
||||
data: {
|
||||
valid: false,
|
||||
shopId: store.shopId,
|
||||
shopName: store.shopName,
|
||||
hasPassId,
|
||||
detail: testResult.message,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 更新店铺配置并自动置为有效
|
||||
const updatedStore = await updatePddStoreCookieFromExtension(store.shopId, {
|
||||
cookie: decrypted.cookie,
|
||||
userAgent: decrypted.userAgent,
|
||||
etag: decrypted.etag,
|
||||
})
|
||||
|
||||
logInfo('[pdd-extension]', `插件成功自动同步店铺 [${store.shopName}(${store.shopId})] Cookie`, {
|
||||
shopId: store.shopId,
|
||||
totalOrders: testResult.totalOrders,
|
||||
lastSyncAt: updatedStore.lastExtensionSyncAt,
|
||||
})
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'Cookie 同步成功并已生效',
|
||||
data: {
|
||||
shopId: updatedStore.shopId,
|
||||
shopName: updatedStore.shopName,
|
||||
valid: true,
|
||||
totalOrders: testResult.totalOrders,
|
||||
lastExtensionSyncAt: updatedStore.lastExtensionSyncAt,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user