feat(pdd): 拼多多订单支持 out_sku_sn 匹配与浏览器插件加密保活

- 拼多多进单 Cookie 失效即时拦截与告警修复(支持 43001 会话过期等错误识别)
- 拼多多订单映射优先使用 out_sku_sn(外部商家编码)作为商品规格与工单匹配核心字段
- 新增 Chrome/Edge 浏览器扩展 (Manifest V3),支持端到端 RSA-OAEP + AES-256-GCM 信封加密自动保活
- 后台店铺管理新增插件配对面板、专属密钥管理与一键导入配置 JSON
This commit is contained in:
yml2213
2026-09-14 12:24:48 +08:00
parent 38c0cf1223
commit 9fb27b0ad2
26 changed files with 2090 additions and 22 deletions
+2
View File
@@ -10,6 +10,7 @@ import claimsRouter from './routes/claims.js'
import kuaishouFeifeiRouter from './routes/kuaishou-feifei.js'
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
import open91Router from './routes/open-91.js'
import pddExtensionRouter from './routes/pdd-extension.js'
import shortLinksRouter from './routes/short-links.js'
import workerRouter from './routes/worker.js'
import { accessLogMiddleware } from './middleware/access-log.js'
@@ -105,6 +106,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
app.use('/api/v1/open/kuaishou-feifei', kuaishouFeifeiRouter)
app.use('/api/v1/claim', claimsRouter)
app.use('/api/v1/collect', collectRouter)
app.use('/api/v1/pdd/extension', pddExtensionRouter)
app.use('/api/v1/files', filesRouter)
app.use('/api/v1/worker', workerRouter)
app.use('/api/v1/admin', adminRouter)
+1 -1
View File
@@ -19,7 +19,7 @@ export function createCorsMiddleware(
}
}
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Shop-Token')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Max-Age', '86400')
@@ -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.
@@ -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()))
}
})
+151
View File
@@ -0,0 +1,151 @@
/**
* 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 testResult = await PddClient.testCookie({
cookie: decrypted.cookie,
userAgent: decrypted.userAgent || store.userAgent,
})
if (!testResult.valid) {
logWarn('[pdd-extension]', `插件上报的 Cookie 实测未通过: ${testResult.message}`, {
shopId: store.shopId,
})
res.status(400).json({
code: 400,
message: `Cookie 校验失败: ${testResult.message}`,
data: {
valid: false,
shopId: store.shopId,
shopName: store.shopName,
},
})
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
@@ -0,0 +1,95 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
getOrCreatePddRsaKeyPair,
getPddExtensionPublicKeyInfo,
decryptPddExtensionPayload,
testEncryptPddPayload,
} from './pdd-crypto-service.js'
test('getOrCreatePddRsaKeyPair returns valid RSA-OAEP 2048 keypair', () => {
const kp = getOrCreatePddRsaKeyPair()
assert.ok(kp.publicKeyPem.includes('BEGIN PUBLIC KEY'))
assert.ok(kp.privateKeyPem.includes('BEGIN PRIVATE KEY'))
assert.ok(kp.publicKeySpkiBase64.length > 100)
const info = getPddExtensionPublicKeyInfo()
assert.equal(info.algorithm, 'RSA-OAEP-256')
assert.equal(info.format, 'SPKI')
assert.ok(info.publicKeySpki.length > 100)
})
test('decryptPddExtensionPayload successfully decrypts valid envelope', () => {
const kp = getOrCreatePddRsaKeyPair()
const payload = {
shopId: 'store_test_01',
cookie: 'PASS_ID=test_pass; rckk=test_rckk',
userAgent: 'Mozilla/5.0 Test Chrome',
timestamp: Date.now(),
nonce: 'nonce-' + Math.random().toString(36).slice(2),
}
const envelope = testEncryptPddPayload(payload, kp.publicKeyPem)
const decrypted = decryptPddExtensionPayload(envelope)
assert.equal(decrypted.shopId, payload.shopId)
assert.equal(decrypted.cookie, payload.cookie)
assert.equal(decrypted.userAgent, payload.userAgent)
assert.equal(decrypted.timestamp, payload.timestamp)
assert.equal(decrypted.nonce, payload.nonce)
})
test('decryptPddExtensionPayload rejects expired timestamp (> 60s)', () => {
const kp = getOrCreatePddRsaKeyPair()
const payload = {
shopId: 'store_test_01',
cookie: 'PASS_ID=test_pass;',
timestamp: Date.now() - 120_000, // 2 minutes ago
nonce: 'nonce-expired-' + Math.random().toString(36).slice(2),
}
const envelope = testEncryptPddPayload(payload, kp.publicKeyPem)
assert.throws(() => decryptPddExtensionPayload(envelope), /请求已过期或时间偏差过大/)
})
test('decryptPddExtensionPayload rejects duplicate nonce (replay attack)', () => {
const kp = getOrCreatePddRsaKeyPair()
const nonce = 'replay-nonce-' + Math.random().toString(36).slice(2)
const payload = {
shopId: 'store_test_01',
cookie: 'PASS_ID=test_pass;',
timestamp: Date.now(),
nonce,
}
const envelope = testEncryptPddPayload(payload, kp.publicKeyPem)
// First attempt succeeds
const first = decryptPddExtensionPayload(envelope)
assert.equal(first.nonce, nonce)
// Second attempt with exact same nonce must fail
assert.throws(() => decryptPddExtensionPayload(envelope), /检测到重放请求/)
})
test('decryptPddExtensionPayload rejects tampered ciphertext', () => {
const kp = getOrCreatePddRsaKeyPair()
const payload = {
shopId: 'store_test_01',
cookie: 'PASS_ID=test_pass;',
timestamp: Date.now(),
nonce: 'nonce-tamper-' + Math.random().toString(36).slice(2),
}
const envelope = testEncryptPddPayload(payload, kp.publicKeyPem)
// Tamper 1 character in cipher
const tamperedCipher =
envelope.cipher.slice(0, 5) +
(envelope.cipher[5] === 'A' ? 'B' : 'A') +
envelope.cipher.slice(6)
assert.throws(
() => decryptPddExtensionPayload({ ...envelope, cipher: tamperedCipher }),
/数据解密或完整性认证失败/,
)
})
@@ -0,0 +1,344 @@
/**
* Pinduoduo Extension End-to-End Encryption Service
* 拼多多浏览器插件端到端加密传输与安全解密服务
*
* Security Architecture / 安全架构:
* 1. Asymmetric Key Exchange (非对称公钥加密):
* - RSA-OAEP 2048-bit with SHA-256 hash.
* - Server generates and persists RSA key pair in data storage.
* - Browser extension receives Server Public Key (SPKI format).
* 2. Symmetric Envelope Encryption (对称信封加密):
* - Extension generates ephemeral AES-256-GCM Key (32B) + IV (12B).
* - Extension encrypts payload using AES-256-GCM with 128-bit Auth Tag.
* - Extension encrypts AES Key using Server's RSA Public Key.
* 3. Anti-Replay & Tamper-Proofing (防重放与防篡改):
* - Millisecond Timestamp validation (max clock skew 60s).
* - Cryptographic Nonce uniqueness verification with TTL cache.
* - AES-GCM Auth Tag integrity check ensures zero tampering.
*/
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js'
import { logInfo, logWarn } from '../../../utils/logger.js'
const PDD_KEYS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'pdd-extension-keys.json')
export interface PddRsaKeyPair {
publicKeyPem: string
privateKeyPem: string
publicKeySpkiBase64: string
createdAt: string
}
export interface PddEncryptedEnvelope {
key: string // Base64 encoded RSA-encrypted AES key
iv: string // Base64 encoded 12-byte AES-GCM IV
tag: string // Base64 encoded 16-byte AES-GCM Auth Tag
cipher: string // Base64 encoded AES-GCM Ciphertext
}
export interface PddDecryptedCookiePayload {
shopId: string
syncToken?: string | undefined
cookie: string
userAgent?: string | undefined
etag?: string | undefined
mallId?: number | string | undefined
timestamp: number
nonce: string
}
// In-memory Nonce cache for anti-replay attacks (TTL: 3 minutes)
const seenNonces = new Map<string, number>()
const NONCE_TTL_MS = 180_000
const MAX_TIMESTAMP_SKEW_MS = 60_000
function cleanupExpiredNonces() {
const now = Date.now()
for (const [nonce, ts] of seenNonces.entries()) {
if (now - ts > NONCE_TTL_MS) {
seenNonces.delete(nonce)
}
}
}
// Memory-cached keypair
let cachedKeyPair: PddRsaKeyPair | null = null
/**
* Clean and convert PEM to raw SPKI Base64 string for Web Crypto API importKey.
*/
export function pemToSpkiBase64(pem: string): string {
return pem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s+/g, '')
}
/**
* Load or initialize the persistent RSA keypair.
*/
export function getOrCreatePddRsaKeyPair(): PddRsaKeyPair {
if (cachedKeyPair) {
return cachedKeyPair
}
try {
if (fs.existsSync(PDD_KEYS_FILE_PATH)) {
const raw = fs.readFileSync(PDD_KEYS_FILE_PATH, 'utf-8')
const parsed = JSON.parse(raw) as Partial<PddRsaKeyPair>
if (parsed.publicKeyPem && parsed.privateKeyPem) {
cachedKeyPair = {
publicKeyPem: parsed.publicKeyPem,
privateKeyPem: parsed.privateKeyPem,
publicKeySpkiBase64: parsed.publicKeySpkiBase64 || pemToSpkiBase64(parsed.publicKeyPem),
createdAt: parsed.createdAt || new Date().toISOString(),
}
return cachedKeyPair
}
}
} catch (err) {
logWarn('[pdd-crypto]', '读取已有拼多多插件密钥对失败,将重新生成', {
error: err instanceof Error ? err.message : String(err),
})
}
// Generate new 2048-bit RSA key pair
logInfo('[pdd-crypto]', '生成新的拼多多插件 RSA-OAEP 2048 密钥对...')
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
})
const newKeyPair: PddRsaKeyPair = {
publicKeyPem: publicKey,
privateKeyPem: privateKey,
publicKeySpkiBase64: pemToSpkiBase64(publicKey),
createdAt: new Date().toISOString(),
}
try {
const dir = path.dirname(PDD_KEYS_FILE_PATH)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(PDD_KEYS_FILE_PATH, JSON.stringify(newKeyPair, null, 2), 'utf-8')
} catch (err) {
logWarn('[pdd-crypto]', '写入拼多多插件密钥对文件失败,保持内存运行', {
error: err instanceof Error ? err.message : String(err),
})
}
cachedKeyPair = newKeyPair
return cachedKeyPair
}
/**
* Get Public Key info for the browser extension.
*/
export function getPddExtensionPublicKeyInfo(): {
algorithm: string
format: string
publicKeyPem: string
publicKeySpki: string
createdAt: string
} {
const kp = getOrCreatePddRsaKeyPair()
return {
algorithm: 'RSA-OAEP-256',
format: 'SPKI',
publicKeyPem: kp.publicKeyPem,
publicKeySpki: kp.publicKeySpkiBase64,
createdAt: kp.createdAt,
}
}
/**
* Decrypt and verify the encrypted envelope sent by the browser extension.
*/
export function decryptPddExtensionPayload(
envelope: PddEncryptedEnvelope,
): PddDecryptedCookiePayload {
if (!envelope || typeof envelope !== 'object') {
throw createHttpError('无效的加密信封数据格式', {
statusCode: 400,
errorCode: 'invalid_envelope_format',
})
}
const { key: encKeyB64, iv: ivB64, tag: tagB64, cipher: cipherB64 } = envelope
if (!encKeyB64 || !ivB64 || !tagB64 || !cipherB64) {
throw createHttpError('加密信封缺少必要字段 (key, iv, tag, cipher)', {
statusCode: 400,
errorCode: 'incomplete_envelope',
})
}
const keyPair = getOrCreatePddRsaKeyPair()
// 1. RSA-OAEP (SHA-256) decrypt the AES-256 key
let aesKey: Buffer
try {
const encKeyBuf = Buffer.from(encKeyB64, 'base64')
aesKey = crypto.privateDecrypt(
{
key: keyPair.privateKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
encKeyBuf,
)
} catch {
throw createHttpError('解密对称密钥失败,公钥可能不匹配或数据已被破坏', {
statusCode: 400,
errorCode: 'rsa_decrypt_failed',
})
}
if (aesKey.length !== 32) {
throw createHttpError('无效的对称密钥长度,期望 256 位', {
statusCode: 400,
errorCode: 'invalid_aes_key_length',
})
}
// 2. AES-256-GCM decrypt the payload ciphertext
let decryptedText: string
try {
const iv = Buffer.from(ivB64, 'base64')
const tag = Buffer.from(tagB64, 'base64')
const cipherBuf = Buffer.from(cipherB64, 'base64')
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv)
decipher.setAuthTag(tag)
const decryptedBuf = Buffer.concat([decipher.update(cipherBuf), decipher.final()])
decryptedText = decryptedBuf.toString('utf-8')
} catch {
throw createHttpError('数据解密或完整性认证失败 (Auth Tag 校验未通过)', {
statusCode: 400,
errorCode: 'gcm_decrypt_failed',
})
}
// 3. Parse JSON payload
let payload: PddDecryptedCookiePayload
try {
payload = JSON.parse(decryptedText) as PddDecryptedCookiePayload
} catch {
throw createHttpError('解密后数据不是合法的 JSON 格式', {
statusCode: 400,
errorCode: 'invalid_decrypted_json',
})
}
// 4. Validate mandatory payload fields
const shopId = String(payload.shopId || '').trim()
const cookie = String(payload.cookie || '').trim()
const timestamp = Number(payload.timestamp || 0)
const nonce = String(payload.nonce || '').trim()
if (!shopId) {
throw createHttpError('报文中缺少有效 shopId', {
statusCode: 400,
errorCode: 'missing_shop_id',
})
}
if (!cookie) {
throw createHttpError('报文中缺少有效 Cookie 字符串', {
statusCode: 400,
errorCode: 'missing_cookie',
})
}
if (!timestamp || isNaN(timestamp)) {
throw createHttpError('报文中缺少有效 timestamp 时间戳', {
statusCode: 400,
errorCode: 'missing_timestamp',
})
}
if (!nonce) {
throw createHttpError('报文中缺少有效 nonce 随机数', {
statusCode: 400,
errorCode: 'missing_nonce',
})
}
// 5. Anti-Replay Check (Timestamp skew <= 60s)
const now = Date.now()
const skew = Math.abs(now - timestamp)
if (skew > MAX_TIMESTAMP_SKEW_MS) {
throw createHttpError(
`请求已过期或时间偏差过大 (偏差: ${Math.round(skew / 1000)}秒,最大允许 60秒)`,
{
statusCode: 400,
errorCode: 'request_timestamp_expired',
},
)
}
// 6. Anti-Replay Check (Nonce uniqueness)
cleanupExpiredNonces()
if (seenNonces.has(nonce)) {
throw createHttpError('检测到重放请求 (Nonce 已被使用)', {
statusCode: 409,
errorCode: 'replay_attack_detected',
})
}
seenNonces.set(nonce, now)
return {
shopId,
cookie,
userAgent: payload.userAgent ? String(payload.userAgent).trim() : undefined,
etag: payload.etag ? String(payload.etag).trim() : undefined,
mallId: payload.mallId,
timestamp,
nonce,
}
}
/**
* Helper for testing: Client-side envelope encryption using Node.js crypto
* (Mimics browser Web Crypto API)
*/
export function testEncryptPddPayload(
payload: Record<string, unknown>,
publicKeyPem: string,
): PddEncryptedEnvelope {
const aesKey = crypto.randomBytes(32)
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv)
const plainText = JSON.stringify(payload)
const cipherBuf = Buffer.concat([cipher.update(plainText, 'utf-8'), cipher.final()])
const tag = cipher.getAuthTag()
const encKey = crypto.publicEncrypt(
{
key: publicKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
aesKey,
)
return {
key: encKey.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64'),
cipher: cipherBuf.toString('base64'),
}
}
@@ -73,3 +73,29 @@ test('mapPddOrderToSourceEvent handles refunded and shipped statuses', () => {
const refundEvent = mapPddOrderToSourceEvent(refundOrder, { shopId: 's1', shopName: '店铺1' })
assert.equal(refundEvent.orderStatus, 'refunded')
})
test('mapPddOrderToSourceEvent prioritizes out_sku_sn as primary matching field', () => {
const pddOrderWithSkuSn: PddRawOrderItem = {
order_sn: '260914-888888',
order_status: 1,
order_status_str: '待发货',
order_amount: 3000,
goods_id: 12345678,
goods_name: '和平精英苏尔南手枪皮肤',
spec: '暗潮1钻',
out_sku_sn: '安卓血宴3钻',
}
const event = mapPddOrderToSourceEvent(pddOrderWithSkuSn, {
shopId: 'shop_pdd',
shopName: '拼多多店铺',
})
const item = event.items[0]!
assert.equal(item.skuCode, '安卓血宴3钻')
assert.equal(item.skuName, '安卓血宴3钻')
assert.equal(item.externalSkuCode, '安卓血宴3钻')
assert.equal(item.externalSkuName, '安卓血宴3钻')
assert.equal((item.snapshot as Record<string, unknown>).out_sku_sn, '安卓血宴3钻')
assert.equal((item.spec as Record<string, unknown>).outSkuSn, '安卓血宴3钻')
assert.equal((item.spec as Record<string, unknown>).goodsName, '和平精英苏尔南手枪皮肤')
assert.equal((item.spec as Record<string, unknown>).spec, '暗潮1钻')
})
@@ -26,6 +26,10 @@ export interface PddRawOrderItem {
nickname?: string
buyer_memo?: string
mall_remark?: string
out_sku_sn?: string
outer_id?: string
out_goods_sn?: string
sku_id?: number | string
[key: string]: unknown
}
@@ -34,6 +38,45 @@ export interface MapPddOrderOptions {
shopName: string
}
/**
* Extract external SKU code (out_sku_sn) from raw PDD order payload.
* 从拼多多原始报文中提取外部商家编码 out_sku_sn(作为派发工单及商品匹配的关键字段)。
*/
export function extractPddOutSkuSn(rawOrder: PddRawOrderItem): string {
const direct =
rawOrder.out_sku_sn ||
rawOrder.outer_id ||
rawOrder.out_goods_sn ||
(rawOrder as any).outSkuSn ||
(rawOrder as any).outerId
if (typeof direct === 'string' && direct.trim()) {
return direct.trim()
}
const nestedCandidates = [
(rawOrder as any).order_items,
(rawOrder as any).order_goods,
(rawOrder as any).items,
(rawOrder as any).order_item_list,
]
for (const list of nestedCandidates) {
if (Array.isArray(list)) {
for (const entry of list) {
const nestedSn =
entry?.out_sku_sn ||
entry?.outer_id ||
entry?.out_goods_sn ||
entry?.outSkuSn ||
entry?.outerId
if (typeof nestedSn === 'string' && nestedSn.trim()) {
return nestedSn.trim()
}
}
}
}
return ''
}
/**
* Maps raw PDD MMS order dictionary into order_site SourceOrderEvent.
* 将拼多多原始订单字典转换为 order_site 平台的 SourceOrderEvent 统一格式。
@@ -77,9 +120,14 @@ export function mapPddOrderToSourceEvent(
const spec = String(rawOrder.spec || '').trim()
const quantity = Math.max(1, Number(rawOrder.goods_number || 1))
// 只展示规格 spec(若无规格则回退使用商品原名)
const skuName = spec || goodsName
const externalSkuCode = goodsIdStr
// 提取商家外部编码 out_sku_sn(如 "安卓血宴3钻"),作为核心匹配字段
const outSkuSn = extractPddOutSkuSn(rawOrder)
// 匹配字段优先使用 out_sku_sn;未配置时回退使用 spec 或 goods_name / goods_id
const skuCode = outSkuSn || goodsIdStr
const skuName = outSkuSn || spec || goodsName
const externalSkuCode = skuCode
const externalSkuName = skuName
// Retain the entire raw snapshot dictionary as requested / 保留原始报文完整快照
const itemSnapshot: JsonObject = {
@@ -87,6 +135,8 @@ export function mapPddOrderToSourceEvent(
goodsId: goodsIdStr,
goodsName,
spec,
out_sku_sn: outSkuSn,
outSkuSn,
quantity,
orderAmount: Number(rawOrder.order_amount || 0),
goodsAmount: Number(rawOrder.goods_amount || 0),
@@ -118,14 +168,15 @@ export function mapPddOrderToSourceEvent(
itemId: goodsIdStr,
externalItemId: goodsIdStr,
externalSkuCode,
externalSkuName: skuName,
skuCode: externalSkuCode,
externalSkuName,
skuCode,
skuName,
quantity,
spec: {
goodsId: goodsIdStr,
goodsName,
spec,
outSkuSn: outSkuSn || undefined,
},
snapshot: itemSnapshot,
},
@@ -3,6 +3,7 @@
* 拼多多多店铺配置与持久化服务 (支持数据库持久化与本地 JSON 双模兜底)
*/
import crypto from 'node:crypto'
import path from 'node:path'
import type { JsonObject } from '../../../types/json.js'
@@ -26,6 +27,8 @@ export interface PddStoreConfigItem {
cookieValid?: boolean | undefined
cookieCheckMessage?: string | undefined
totalOrders?: number | undefined
syncToken?: string | undefined
lastExtensionSyncAt?: string | undefined
}
export interface PddSourcesConfig {
@@ -166,6 +169,76 @@ export async function deletePddStore(shopId: unknown): Promise<PddSourcesConfig>
return config
}
/**
* Generate a cryptographically secure sync token for a store.
* 为指定店铺生成专属浏览器插件配对同步密钥。
*/
export async function generatePddStoreSyncToken(shopId: unknown): Promise<string> {
const store = getPddStoreById(shopId)
if (!store) {
throw createHttpError(`店铺不存在: ${String(shopId)}`, {
statusCode: 404,
errorCode: 'store_not_found',
})
}
const newToken = 'pdd_tok_' + crypto.randomBytes(24).toString('hex')
await savePddStore(store.shopId, { ...store, syncToken: newToken })
return newToken
}
/**
* Verify whether a syncToken matches the configured token for a store.
* 验证插件上传的配对密钥是否与店铺配置匹配(使用恒定时间比较防时序攻击)。
*/
export function verifyPddStoreSyncToken(shopId: unknown, token: unknown): boolean {
const store = getPddStoreById(shopId)
if (!store || !store.syncToken) {
return false
}
const expected = String(store.syncToken).trim()
const actual = String(token || '').trim()
if (!expected || !actual) {
return false
}
if (Buffer.byteLength(expected) !== Buffer.byteLength(actual)) {
return false
}
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual))
}
/**
* Update a store's cookie from browser extension sync.
* 从浏览器插件更新店铺 Cookie 并自动置为有效。
*/
export async function updatePddStoreCookieFromExtension(
shopId: unknown,
data: {
cookie: string
userAgent?: string | undefined
etag?: string | undefined
},
): Promise<PddStoreConfigItem> {
const store = getPddStoreById(shopId)
if (!store) {
throw createHttpError(`店铺不存在: ${String(shopId)}`, {
statusCode: 404,
errorCode: 'store_not_found',
})
}
const now = new Date().toISOString()
return await savePddStore(store.shopId, {
...store,
cookie: data.cookie,
userAgent: data.userAgent || store.userAgent,
etag: data.etag || store.etag,
cookieValid: true,
cookieCheckMessage: '浏览器插件自动更新',
lastCheckedAt: now,
lastExtensionSyncAt: now,
})
}
/**
* Standardize single store item configuration.
* 标准化单个店铺配置项。
@@ -186,6 +259,8 @@ export function normalizePddStoreItem(rawValue: unknown): PddStoreConfigItem {
const totalOrders = Number.isInteger(Number(source.totalOrders))
? Number(source.totalOrders)
: undefined
const syncToken = String(source.syncToken || '').trim() || undefined
const lastExtensionSyncAt = String(source.lastExtensionSyncAt || '').trim() || undefined
return {
shopId,
@@ -199,6 +274,8 @@ export function normalizePddStoreItem(rawValue: unknown): PddStoreConfigItem {
cookieValid,
cookieCheckMessage,
totalOrders,
syncToken,
lastExtensionSyncAt,
}
}
@@ -155,3 +155,21 @@ test('syncStoreOrders stops on page 1 if explicit pageNumber > 1 is specified',
PddClient.prototype.queryRecentOrders = originalQuery
}
})
test('isPddCookieExpiredError accurately detects 43001 and session expired variants', async () => {
const { isPddCookieExpiredError } = await import('./pdd-sync-service.js')
// Real PDD API error from server logs
assert.equal(
isPddCookieExpiredError('PDD API Error [43001]: 会话已过期 (Shop: store_pdd_01)'),
true,
)
assert.equal(isPddCookieExpiredError('PDD API Error [40001]: 登录已过期,请重新登录'), true)
assert.equal(
isPddCookieExpiredError('PDD API Error [40002]: 操作过于频繁,请稍后再试或完成滑块验证'),
true,
)
assert.equal(isPddCookieExpiredError('PDD HTTP 401 Error: Unauthorized'), true)
assert.equal(isPddCookieExpiredError('网络连接超时: fetch failed'), false)
assert.equal(isPddCookieExpiredError('ECONNRESET'), false)
})
@@ -57,6 +57,38 @@ export interface SyncAllStoresResult {
results: SyncStoreResult[]
}
/**
* Detect whether an error string indicates that PDD Cookie/session has expired or requires re-login.
* 检测错误信息是否代表拼多多 Cookie/会话已过期、未登录或被风控拦截
*/
export function isPddCookieExpiredError(errorMsg: string): boolean {
const msg = String(errorMsg || '').toLowerCase()
const expiredCodes = ['43001', '40001', '40002', '43002', '43003', '10000']
if (expiredCodes.some((code) => msg.includes(code))) {
return true
}
const expiredKeywords = [
'过期',
'失效',
'未登录',
'重新登录',
'会话已过期',
'登录超时',
'请先登录',
'登录状态已失效',
'cookie',
'token',
'session',
'unauthorized',
'forbidden',
'verify',
'captcha',
'风控',
]
return expiredKeywords.some((kw) => msg.includes(kw))
}
/**
* Synchronize orders for a single PDD store with adaptive pagination.
* 同步单个拼多多店铺的订单并触发进单流程(支持自适应翻页拉取)。
@@ -267,13 +299,7 @@ export async function syncStoreOrders(
// If login expired or risk-controlled, mark cookie as invalid and notify admins
// 若登录过期或风控拦截,标记为失效并推送告警通知
if (
errMsg.includes('40001') ||
errMsg.includes('登录') ||
errMsg.includes('失效') ||
errMsg.includes('未登录') ||
errMsg.includes('40002')
) {
if (isPddCookieExpiredError(errMsg)) {
await updatePddStoreCheckStatus(shopId, {
cookieValid: false,
cookieCheckMessage: errMsg,
@@ -62,8 +62,11 @@ export async function syncWorkerOrdersForSourceOrder(
const itemTitle =
matchContext.itemTitle ||
String(snapshot.goodsName || snapshot.goods_name || item.sku_name || '').trim()
const relSkuId = matchContext.relSkuId || String(snapshot.spec || item.sku_code || '').trim()
const skuNick = matchContext.skuNick || String(snapshot.spec || item.sku_name || '').trim()
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
const relSkuId =
matchContext.relSkuId || String(outSkuSn || snapshot.spec || item.sku_code || '').trim()
const skuNick =
matchContext.skuNick || String(outSkuSn || snapshot.spec || item.sku_name || '').trim()
// Record product match diagnostics for all platform orders (including PDD)
if (sellerId || relItemId || itemTitle) {
@@ -149,14 +149,22 @@ export function resolveKuaishouWorkProductMatchContext(
): KuaishouWorkProductMatchContext {
const snapshot = safeParseJson(item.item_snapshot_json)
const source = safeParseJson(snapshot.kuaishouSendCode)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
return {
sellerId: String(source.sellerId || '').trim(),
itemId: String(source.itemId || '').trim(),
relItemId: normalizeExternalMatchId(source.relItemId),
skuId: String(source.skuId || '').trim(),
relSkuId: normalizeExternalMatchId(source.relSkuId),
itemTitle: String(source.itemTitle || '').trim(),
skuNick: String(source.skuNick || source.skuName || snapshot.externalSkuName || '').trim(),
relItemId: normalizeExternalMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
skuId: String(source.skuId || snapshot.sku_id || '').trim(),
relSkuId: normalizeExternalMatchId(source.relSkuId || outSkuSn || snapshot.spec),
itemTitle: String(source.itemTitle || snapshot.goodsName || snapshot.goods_name || '').trim(),
skuNick: String(
source.skuNick ||
source.skuName ||
outSkuSn ||
snapshot.externalSkuName ||
snapshot.spec ||
'',
).trim(),
}
}
@@ -284,12 +292,15 @@ function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkPro
return { rule, score }
}
const skuCodes = [item.sku_code, snapshot.externalSkuCode, snapshot.externalItemId]
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
const skuCodes = [item.sku_code, snapshot.externalSkuCode, outSkuSn, snapshot.externalItemId]
.map(normalizeMatchId)
.filter(Boolean)
const skuNames = [
item.sku_name,
snapshot.externalSkuName,
outSkuSn,
snapshot.spec,
snapshot.externalSkuNameNormalized,
source.skuNick,
source.itemTitle,
@@ -576,6 +576,32 @@ test('商品映射不以公共词包含匹配,完整大标题不同则不命
assert.equal(decision.rule, null)
})
test('拼多多订单使用 out_sku_sn 字段精准匹配接单规则', () => {
const rule = buildProductRule({
provider: 'pdd',
platform: 'pdd',
sku_code: '安卓血宴3钻',
product_name: '安卓血宴3钻',
match_type: 'exact',
})
const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd' }
const item = {
...buildOrderItemRow(),
sku_code: '安卓血宴3钻',
sku_name: '安卓血宴3钻',
item_snapshot_json: {
goods_id: 12345678,
goods_name: '和平精英苏尔南手枪皮肤',
spec: '暗潮1钻',
out_sku_sn: '安卓血宴3钻',
},
}
const decision = resolveMatchingProductRuleDecision(order, item, [rule], [])
assert.equal(decision.reason, 'matched')
assert.equal(decision.rule?.id, rule.id)
})
test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_rejected', () => {
assert.throws(
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })),