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:
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
})
|
||||
@@ -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' })),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 拼多多商家后台 Cookie 自动保活扩展 (Chrome / Edge Extension)
|
||||
|
||||
本浏览器扩展程序专为解决拼多多商家后台(MMS)会话 Cookie 易过期、人工每日 F12 抓包容易遗忘与导致系统漏单而设计。
|
||||
|
||||
---
|
||||
|
||||
## 核心工作原理与安全机制
|
||||
|
||||
1. **自动嗅探与监听(Silent Capture)**:
|
||||
- 监听 `mms.pinduoduo.com` 的 Cookie 变更事件;
|
||||
- 自动在后台提取新鲜的会话凭证(包含 `PASS_ID`、`rckk` 等);
|
||||
- 内置 SHA-256 指纹比对去重机制,无实质变动时不发起网络请求,绝不浪费带宽与频控。
|
||||
|
||||
2. **端到端信封加密(End-to-End Envelope Encryption)**:
|
||||
- **传输层**:强制走 HTTPS / TLS 1.3 通道;
|
||||
- **应用层**:使用浏览器原生 Web Crypto API 生成随机 256 位 AES-GCM 对称密钥;
|
||||
- **非对称公钥包装**:使用服务端的 RSA-2048 公钥(RSA-OAEP SHA-256)加密 AES 密钥;
|
||||
- **防重放与防篡改**:每个数据包附带 60 秒失效时间戳与加密 Nonce,服务端做幂等校验,AES-GCM Auth Tag 确保任意环节防篡改。
|
||||
- **无私钥泄漏风险**:扩展仅内置/获取公钥,即使他人获取插件源码也绝对无法解密回传数据包。
|
||||
|
||||
3. **双向鉴权与店铺绑定(Pairing Token)**:
|
||||
- 每个店铺在管理后台均有独立的 `syncToken`;
|
||||
- 只有持有该配对密钥的插件上报才会被服务端接受。
|
||||
|
||||
---
|
||||
|
||||
## 安装使用指南(面向客服与运营人员)
|
||||
|
||||
### 第一步:安装插件到 Chrome / Edge 浏览器
|
||||
|
||||
1. 打开 Chrome 或 Edge 浏览器;
|
||||
2. 在地址栏输入并前往扩展管理页:
|
||||
- Chrome 浏览器:`chrome://extensions`
|
||||
- Edge 浏览器:`edge://extensions`
|
||||
3. 开启页面右上角的 **【开发者模式】(Developer mode)**;
|
||||
4. 点击左上角出现的 **【加载已解压的扩展程序】(Load unpacked)** 按钮;
|
||||
5. 在文件选择窗口中,选中本项目目录下的:
|
||||
`apps/extensions/pdd-cookie-sync`
|
||||
6. 确认加载,浏览器右上角扩展栏将出现「拼多多 Cookie 自动保活」图标。
|
||||
|
||||
---
|
||||
|
||||
### 第二步:配对店铺与配置
|
||||
|
||||
1. 打开管理系统后台 -> **【平台配置】** -> **【拼多多平台】**;
|
||||
2. 找到需要保活的目标店铺,在表格操作列点击 **【插件配对】** 按钮;
|
||||
3. 弹窗中点击 **【一键复制完整配置】**;
|
||||
4. 点击浏览器右上角扩展图标,在弹窗中点击 **【一键导入配置 JSON】**,粘贴刚才复制的内容,点击 **【解析并填充】**;
|
||||
5. 点击 **【保存配置并重连】**;
|
||||
6. 插件立即发起第一次加密上传与连通性验证。
|
||||
|
||||
---
|
||||
|
||||
### 第三步:日常使用与状态观察
|
||||
|
||||
- **正常运行(角标显示绿色 OK)**:
|
||||
- 客服日常只需像往常一样在浏览器中登录拼多多商家后台(`mms.pinduoduo.com`);
|
||||
- 只要商家后台处于登录状态,插件会在后台自动完成 Cookie 刷新与加密上报,服务端同步生效,全程无感。
|
||||
- **角标显示红色 ERR**:
|
||||
- 代表配对密钥错误或接口网络受阻,点击插件图标可查看具体错误原因。
|
||||
- **角标显示黄色 OFF / NO_CK**:
|
||||
- 代表当前浏览器未登录拼多多商家后台,只需在网页中正常登录即可自动恢复。
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Pinduoduo Cookie Sync Background Service Worker (Manifest V3)
|
||||
* 拼多多 Cookie 自动嗅探与加密保活后台服务
|
||||
*/
|
||||
|
||||
try {
|
||||
importScripts('crypto.js')
|
||||
} catch (e) {
|
||||
console.error('导入加密工具失败:', e)
|
||||
}
|
||||
|
||||
const ALARM_NAME = 'pdd_cookie_sync_alarm'
|
||||
let debounceTimer = null
|
||||
|
||||
// 设置扩展图标角标状态
|
||||
function setExtensionBadge(text, color) {
|
||||
chrome.action.setBadgeText({ text })
|
||||
if (color) {
|
||||
chrome.action.setBadgeBackgroundColor({ color })
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拼多多全量 Cookie 字符串
|
||||
async function getPddCookieString() {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ domain: 'pinduoduo.com' })
|
||||
if (!cookies || cookies.length === 0) {
|
||||
return ''
|
||||
}
|
||||
// 拼接成标准 Cookie 请求头格式
|
||||
return cookies.map((c) => `${c.name}=${c.value}`).join('; ')
|
||||
} catch (err) {
|
||||
console.error('读取拼多多 Cookie 异常:', err)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 核心同步逻辑
|
||||
async function syncCookieToServer(isManual = false) {
|
||||
const config = await chrome.storage.local.get([
|
||||
'serverUrl',
|
||||
'shopId',
|
||||
'syncToken',
|
||||
'lastCookieHash',
|
||||
'lastSyncAt',
|
||||
])
|
||||
|
||||
const serverUrl = String(config.serverUrl || '').trim().replace(/\/+$/, '')
|
||||
const shopId = String(config.shopId || '').trim()
|
||||
const syncToken = String(config.syncToken || '').trim()
|
||||
|
||||
if (!serverUrl || !shopId || !syncToken) {
|
||||
setExtensionBadge('CFG', '#faad14') // 待配置
|
||||
return {
|
||||
success: false,
|
||||
message: '请先打开插件弹窗完成服务器地址与店铺配对密钥配置',
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 读取当前 Cookie
|
||||
const cookieStr = await getPddCookieString()
|
||||
if (!cookieStr) {
|
||||
setExtensionBadge('OFF', '#8c8c8c') // 未登录
|
||||
await chrome.storage.local.set({
|
||||
lastStatus: 'offline',
|
||||
lastMessage: '未在浏览器中检测到拼多多商家后台登录 Cookie',
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
message: '未检测到拼多多登录 Cookie,请先在当前浏览器中登录 mms.pinduoduo.com',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算 Cookie 哈希并进行指纹去重
|
||||
const currentHash = await computeSha256(cookieStr)
|
||||
const now = Date.now()
|
||||
const lastSyncTime = Number(config.lastSyncAt || 0)
|
||||
|
||||
// 若非手动强制触发,且 Cookie 未变动,且距离上次同步不足 1 小时,跳过重复上报
|
||||
if (!isManual && config.lastCookieHash === currentHash && now - lastSyncTime < 3600_000) {
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'Cookie 无实质变动,无需重复上报',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. 请求服务端公钥
|
||||
const pkUrl = `${serverUrl}/api/v1/pdd/extension/public-key`
|
||||
const pkResp = await fetch(pkUrl, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!pkResp.ok) {
|
||||
throw new Error(`获取公钥失败 HTTP ${pkResp.status}`)
|
||||
}
|
||||
|
||||
const pkJson = await pkResp.json()
|
||||
const serverPublicKeySpki = pkJson?.data?.publicKeySpki
|
||||
if (!serverPublicKeySpki) {
|
||||
throw new Error('服务端返回的 RSA 公钥格式异常')
|
||||
}
|
||||
|
||||
// 4. 构建明文载荷(syncToken 在加密包内外双重保护)
|
||||
const payload = {
|
||||
shopId,
|
||||
syncToken,
|
||||
cookie: cookieStr,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now(),
|
||||
nonce: 'ext_' + Math.random().toString(36).slice(2) + Date.now().toString(36),
|
||||
}
|
||||
|
||||
// 5. 执行端到端混合信封加密 (RSA-OAEP + AES-256-GCM)
|
||||
const envelope = await encryptPayloadWithEnvelope(payload, serverPublicKeySpki)
|
||||
|
||||
// 6. 安全回传至服务端
|
||||
const syncUrl = `${serverUrl}/api/v1/pdd/extension/sync-cookie`
|
||||
const syncResp = await fetch(syncUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shop-Token': syncToken,
|
||||
},
|
||||
body: JSON.stringify({ envelope }),
|
||||
})
|
||||
|
||||
const syncJson = await syncResp.json()
|
||||
|
||||
if (!syncResp.ok || syncJson.code !== 0) {
|
||||
const errMsg = syncJson?.message || `服务端返回错误 HTTP ${syncResp.status}`
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
// 同步成功,更新持久化状态
|
||||
await chrome.storage.local.set({
|
||||
lastCookieHash: currentHash,
|
||||
lastSyncAt: now,
|
||||
lastStatus: 'ok',
|
||||
lastMessage: syncJson.message || '同步成功并已生效',
|
||||
totalOrders: syncJson.data?.totalOrders,
|
||||
})
|
||||
|
||||
setExtensionBadge('OK', '#52c41a') // 绿色成功
|
||||
return {
|
||||
success: true,
|
||||
message: 'Cookie 同步成功!服务端已完成在线校验并持久化生效',
|
||||
totalOrders: syncJson.data?.totalOrders,
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
console.error('Cookie 自动同步失败:', errMsg)
|
||||
|
||||
await chrome.storage.local.set({
|
||||
lastStatus: 'error',
|
||||
lastMessage: errMsg,
|
||||
})
|
||||
|
||||
setExtensionBadge('ERR', '#ff4d4f') // 红色错误
|
||||
return {
|
||||
success: false,
|
||||
message: errMsg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听拼多多 Cookie 实时变动事件(带防抖延时 3 秒)
|
||||
chrome.cookies.onChanged.addListener((changeInfo) => {
|
||||
const domain = changeInfo.cookie?.domain || ''
|
||||
if (domain.includes('pinduoduo.com')) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
syncCookieToServer(false).catch(() => null)
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
|
||||
// 定时任务:每隔 30 分钟进行一次轻量巡检与必要保活上报
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === ALARM_NAME) {
|
||||
syncCookieToServer(false).catch(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
// 扩展安装或更新时注册定时闹钟
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.alarms.create(ALARM_NAME, {
|
||||
periodInMinutes: 30,
|
||||
})
|
||||
// 首次运行尝试同步一次
|
||||
syncCookieToServer(false).catch(() => null)
|
||||
})
|
||||
|
||||
// 响应来自 popup.js 的前端交互消息
|
||||
chrome.runtime.onMessage.addListener((req, _sender, sendResponse) => {
|
||||
if (req.action === 'trigger_sync') {
|
||||
syncCookieToServer(true).then(sendResponse).catch((err) => {
|
||||
sendResponse({ success: false, message: String(err) })
|
||||
})
|
||||
return true // 保持异步通信通道
|
||||
}
|
||||
|
||||
if (req.action === 'get_status') {
|
||||
getPddCookieString().then((ck) => {
|
||||
chrome.storage.local.get(null).then((data) => {
|
||||
sendResponse({
|
||||
hasCookie: Boolean(ck && ck.length > 20),
|
||||
cookieLength: ck ? ck.length : 0,
|
||||
storage: data,
|
||||
})
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Pinduoduo Extension Web Crypto Envelope Encryption Module
|
||||
* 基于浏览器原生 Web Crypto API 的端到端信封加密工具库(无任何第三方依赖)
|
||||
*/
|
||||
|
||||
// 将 Base64 字符串转换为 ArrayBuffer
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binaryString = atob(base64.replace(/\s+/g, ''))
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
// 将 ArrayBuffer / Uint8Array 转换为 Base64 字符串
|
||||
function arrayBufferToBase64(buffer) {
|
||||
let binary = ''
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const len = bytes.byteLength
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
// 导入服务端 RSA-OAEP (SHA-256) 公钥 (SPKI 格式)
|
||||
async function importRsaPublicKey(spkiBase64) {
|
||||
const cleanSpki = spkiBase64
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
||||
.replace(/-----END PUBLIC KEY-----/, '')
|
||||
.replace(/\s+/g, '')
|
||||
|
||||
const keyBuffer = base64ToArrayBuffer(cleanSpki)
|
||||
return await crypto.subtle.importKey(
|
||||
'spki',
|
||||
keyBuffer,
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
false,
|
||||
['encrypt'],
|
||||
)
|
||||
}
|
||||
|
||||
// 计算文本 SHA-256 哈希(用于 Cookie 指纹比对)
|
||||
async function computeSha256(text) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(text)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行混合信封加密
|
||||
* 1. 生成一次性 256 位 AES-GCM 密钥 + 12 字节 IV
|
||||
* 2. 用 AES-256-GCM 加密明文载荷,分离密文与 128 位认证标签 (Tag)
|
||||
* 3. 用服务端 RSA 公钥加密 AES 密钥
|
||||
* 4. 返回标准信封包 { key, iv, tag, cipher }
|
||||
*/
|
||||
async function encryptPayloadWithEnvelope(payload, serverPublicKeySpki) {
|
||||
const cryptoObj = self.crypto || window.crypto
|
||||
if (!cryptoObj || !cryptoObj.subtle) {
|
||||
throw new Error('当前浏览器环境不支持 Web Crypto API')
|
||||
}
|
||||
|
||||
// 1. 生成一次性 AES-GCM 对称密钥
|
||||
const aesKey = await cryptoObj.subtle.generateKey(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256,
|
||||
},
|
||||
true,
|
||||
['encrypt'],
|
||||
)
|
||||
|
||||
// 2. 生成 12 字节加密 IV
|
||||
const iv = cryptoObj.getRandomValues(new Uint8Array(12))
|
||||
|
||||
// 3. 使用 AES-256-GCM 加密明文 JSON
|
||||
const encoder = new TextEncoder()
|
||||
const plainBytes = encoder.encode(JSON.stringify(payload))
|
||||
const encryptedBuf = await cryptoObj.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
tagLength: 128,
|
||||
},
|
||||
aesKey,
|
||||
plainBytes,
|
||||
)
|
||||
|
||||
// Web Crypto AES-GCM 的输出格式为: [密文 (前 N 字节)] + [认证标签 Tag (后 16 字节)]
|
||||
const totalLen = encryptedBuf.byteLength
|
||||
const cipherBytes = encryptedBuf.slice(0, totalLen - 16)
|
||||
const tagBytes = encryptedBuf.slice(totalLen - 16)
|
||||
|
||||
// 4. 导出对称密钥原始字节 (32 字节)
|
||||
const rawAesKey = await cryptoObj.subtle.exportKey('raw', aesKey)
|
||||
|
||||
// 5. 导入服务端 RSA 公钥并加密 AES 密钥
|
||||
const rsaKey = await importRsaPublicKey(serverPublicKeySpki)
|
||||
const encKeyBuf = await cryptoObj.subtle.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
},
|
||||
rsaKey,
|
||||
rawAesKey,
|
||||
)
|
||||
|
||||
return {
|
||||
key: arrayBufferToBase64(encKeyBuf),
|
||||
iv: arrayBufferToBase64(iv),
|
||||
tag: arrayBufferToBase64(tagBytes),
|
||||
cipher: arrayBufferToBase64(cipherBytes),
|
||||
}
|
||||
}
|
||||
|
||||
// 导出全局函数供 background.js 与 popup.js 引用
|
||||
if (typeof self !== 'undefined') {
|
||||
self.encryptPayloadWithEnvelope = encryptPayloadWithEnvelope
|
||||
self.computeSha256 = computeSha256
|
||||
self.importRsaPublicKey = importRsaPublicKey
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 863 B |
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "拼多多 Cookie 自动保活同步",
|
||||
"version": "1.0.0",
|
||||
"description": "自动提取拼多多商家后台新鲜 Cookie 并通过端到端 RSA+AES-GCM 信封加密安全同步至业务总平台",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://mms.pinduoduo.com/*",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "拼多多 Cookie 保活配置"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon.png",
|
||||
"48": "icon.png",
|
||||
"128": "icon.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>拼多多 Cookie 自动保活</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
width: 360px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-ok { background: #dcfce7; color: #15803d; }
|
||||
.badge-err { background: #fee2e2; color: #b91c1c; }
|
||||
.badge-gray { background: #f1f5f9; color: #64748b; }
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.label {
|
||||
color: #64748b;
|
||||
}
|
||||
.val {
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, textarea:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.1);
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #334155;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.security-tag {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.msg-box {
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
}
|
||||
.msg-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.msg-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span>拼多多 Cookie 保活</span>
|
||||
</div>
|
||||
<div id="statusBadge" class="badge badge-gray">未检测</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态概览卡片 -->
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<span class="label">MMS 登录状态:</span>
|
||||
<span id="mmsStatus" class="val">检测中...</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">绑定店铺:</span>
|
||||
<span id="shopDisplay" class="val">未配置</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">上次同步:</span>
|
||||
<span id="lastSyncDisplay" class="val">未同步</span>
|
||||
</div>
|
||||
<div class="row" id="orderCountRow" style="display: none;">
|
||||
<span class="label">在售/历史单:</span>
|
||||
<span id="orderCountDisplay" class="val" style="color: #2563eb;">0 笔</span>
|
||||
</div>
|
||||
<div id="statusMsg" class="msg-box"></div>
|
||||
</div>
|
||||
|
||||
<!-- 手动立即同步按钮 -->
|
||||
<button id="btnSyncNow" class="btn btn-primary">立即上传同步 Cookie</button>
|
||||
|
||||
<!-- 配置折叠区 -->
|
||||
<div style="margin-top: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-weight: 600; font-size: 12px; color: #475569;">连接配置</span>
|
||||
<a href="#" id="btnToggleImport" style="font-size: 11px; color: #2563eb; text-decoration: none;">一键导入配置 JSON</a>
|
||||
</div>
|
||||
|
||||
<!-- 导入 JSON 框(默认隐藏) -->
|
||||
<div id="importArea" style="display: none; margin-bottom: 10px;">
|
||||
<textarea id="importJsonInput" rows="3" placeholder="在此粘贴后台复制的完整配置 JSON..."></textarea>
|
||||
<button id="btnApplyImport" class="btn btn-secondary" style="margin-top: 4px; padding: 5px;">解析并填充</button>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 8px;">
|
||||
<div class="form-group">
|
||||
<label for="inputServerUrl">服务端 API 地址</label>
|
||||
<input type="text" id="inputServerUrl" placeholder="例如:https://order.domain.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inputShopId">店铺标识 (Shop ID)</label>
|
||||
<input type="text" id="inputShopId" placeholder="例如:store_pdd_01">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="inputSyncToken">专属配对密钥 (Sync Token)</label>
|
||||
<input type="password" id="inputSyncToken" placeholder="例如:pdd_tok_xxx">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btnSaveConfig" class="btn btn-secondary">保存配置并重连</button>
|
||||
</div>
|
||||
|
||||
<div class="security-tag">
|
||||
<span>🛡️ RSA-OAEP + AES-256-GCM 端到端信封加密</span>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Pinduoduo Cookie Sync Extension Popup Script
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const statusBadge = document.getElementById('statusBadge')
|
||||
const mmsStatus = document.getElementById('mmsStatus')
|
||||
const shopDisplay = document.getElementById('shopDisplay')
|
||||
const lastSyncDisplay = document.getElementById('lastSyncDisplay')
|
||||
const orderCountRow = document.getElementById('orderCountRow')
|
||||
const orderCountDisplay = document.getElementById('orderCountDisplay')
|
||||
const statusMsg = document.getElementById('statusMsg')
|
||||
|
||||
const btnSyncNow = document.getElementById('btnSyncNow')
|
||||
const btnSaveConfig = document.getElementById('btnSaveConfig')
|
||||
const btnToggleImport = document.getElementById('btnToggleImport')
|
||||
const btnApplyImport = document.getElementById('btnApplyImport')
|
||||
const importArea = document.getElementById('importArea')
|
||||
const importJsonInput = document.getElementById('importJsonInput')
|
||||
|
||||
const inputServerUrl = document.getElementById('inputServerUrl')
|
||||
const inputShopId = document.getElementById('inputShopId')
|
||||
const inputSyncToken = document.getElementById('inputSyncToken')
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '未同步'
|
||||
const d = new Date(timestamp)
|
||||
if (isNaN(d.getTime())) return '未同步'
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
const s = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${m}-${day} ${h}:${min}:${s}`
|
||||
}
|
||||
|
||||
function showMsg(text, isError = false) {
|
||||
statusMsg.style.display = 'block'
|
||||
statusMsg.className = isError ? 'msg-box msg-error' : 'msg-box msg-success'
|
||||
statusMsg.textContent = text
|
||||
}
|
||||
|
||||
function refreshStatusView(statusData) {
|
||||
const storage = statusData?.storage || {}
|
||||
const hasCookie = Boolean(statusData?.hasCookie)
|
||||
|
||||
// 1. MMS 登录状态
|
||||
if (hasCookie) {
|
||||
mmsStatus.textContent = `已在线 (长度: ${statusData.cookieLength || 0})`
|
||||
mmsStatus.style.color = '#15803d'
|
||||
} else {
|
||||
mmsStatus.textContent = '未登录 / 无有效Cookie'
|
||||
mmsStatus.style.color = '#b91c1c'
|
||||
}
|
||||
|
||||
// 2. 店铺标识
|
||||
if (storage.shopId) {
|
||||
shopDisplay.textContent = storage.shopId
|
||||
} else {
|
||||
shopDisplay.textContent = '未配置'
|
||||
}
|
||||
|
||||
// 3. 上次同步
|
||||
if (storage.lastSyncAt) {
|
||||
lastSyncDisplay.textContent = formatTime(storage.lastSyncAt)
|
||||
} else {
|
||||
lastSyncDisplay.textContent = '从未同步'
|
||||
}
|
||||
|
||||
// 4. 订单数
|
||||
if (storage.totalOrders !== undefined) {
|
||||
orderCountRow.style.display = 'flex'
|
||||
orderCountDisplay.textContent = `${storage.totalOrders} 笔`
|
||||
} else {
|
||||
orderCountRow.style.display = 'none'
|
||||
}
|
||||
|
||||
// 5. 顶部角标与错误消息
|
||||
if (!storage.serverUrl || !storage.shopId || !storage.syncToken) {
|
||||
statusBadge.className = 'badge badge-gray'
|
||||
statusBadge.textContent = '待配对'
|
||||
} else if (storage.lastStatus === 'ok') {
|
||||
statusBadge.className = 'badge badge-ok'
|
||||
statusBadge.textContent = '运行正常'
|
||||
if (storage.lastMessage) {
|
||||
showMsg(storage.lastMessage, false)
|
||||
}
|
||||
} else if (storage.lastStatus === 'error') {
|
||||
statusBadge.className = 'badge badge-err'
|
||||
statusBadge.textContent = '同步异常'
|
||||
if (storage.lastMessage) {
|
||||
showMsg(storage.lastMessage, true)
|
||||
}
|
||||
} else {
|
||||
statusBadge.className = 'badge badge-gray'
|
||||
statusBadge.textContent = '待同步'
|
||||
}
|
||||
|
||||
// 填充输入框
|
||||
inputServerUrl.value = storage.serverUrl || ''
|
||||
inputShopId.value = storage.shopId || ''
|
||||
inputSyncToken.value = storage.syncToken || ''
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
chrome.runtime.sendMessage({ action: 'get_status' }, (res) => {
|
||||
refreshStatusView(res)
|
||||
})
|
||||
|
||||
// 切换一键导入框
|
||||
btnToggleImport.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
if (importArea.style.display === 'none') {
|
||||
importArea.style.display = 'block'
|
||||
importJsonInput.focus()
|
||||
} else {
|
||||
importArea.style.display = 'none'
|
||||
}
|
||||
})
|
||||
|
||||
// 执行一键导入解析
|
||||
btnApplyImport.addEventListener('click', () => {
|
||||
const raw = importJsonInput.value.trim()
|
||||
if (!raw) return
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed.serverUrl) inputServerUrl.value = parsed.serverUrl
|
||||
if (parsed.shopId) inputShopId.value = parsed.shopId
|
||||
if (parsed.syncToken) inputSyncToken.value = parsed.syncToken
|
||||
importArea.style.display = 'none'
|
||||
showMsg('配置解析成功,请点击下方【保存配置】生效', false)
|
||||
} catch {
|
||||
showMsg('JSON 格式不正确,请确保完整复制后台内容', true)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存配置
|
||||
btnSaveConfig.addEventListener('click', async () => {
|
||||
const serverUrl = inputServerUrl.value.trim()
|
||||
const shopId = inputShopId.value.trim()
|
||||
const syncToken = inputSyncToken.value.trim()
|
||||
|
||||
if (!serverUrl || !shopId || !syncToken) {
|
||||
showMsg('请完整填写服务器地址、店铺标识和配对密钥', true)
|
||||
return
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({
|
||||
serverUrl,
|
||||
shopId,
|
||||
syncToken,
|
||||
})
|
||||
|
||||
showMsg('配置已保存!正在尝试初次加密同步...', false)
|
||||
triggerSync()
|
||||
})
|
||||
|
||||
// 手动触发同步
|
||||
function triggerSync() {
|
||||
btnSyncNow.disabled = true
|
||||
btnSyncNow.textContent = '正在加密上传...'
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'trigger_sync' }, (res) => {
|
||||
btnSyncNow.disabled = false
|
||||
btnSyncNow.textContent = '立即上传同步 Cookie'
|
||||
|
||||
if (res && res.success) {
|
||||
showMsg(res.message || '同步成功!', false)
|
||||
} else {
|
||||
showMsg(res?.message || '同步失败,请检查网络或配置', true)
|
||||
}
|
||||
|
||||
// 刷新界面
|
||||
chrome.runtime.sendMessage({ action: 'get_status' }, (s) => {
|
||||
refreshStatusView(s)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
btnSyncNow.addEventListener('click', triggerSync)
|
||||
})
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FieldTimeOutlined,
|
||||
GlobalOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -48,6 +50,7 @@ import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
deleteAdminPddStore,
|
||||
fetchAdminPddConfig,
|
||||
generateAdminPddStoreSyncToken,
|
||||
saveAdminPddConfig,
|
||||
saveAdminPddStore,
|
||||
testAdminPddCookie,
|
||||
@@ -118,6 +121,35 @@ export function PddPlatformPanel() {
|
||||
const [modalTesting, setModalTesting] = useState(false)
|
||||
const [modalTestResult, setModalTestResult] = useState<AdminPddTestCookieResult | null>(null)
|
||||
|
||||
// Extension Pairing Modal State / 浏览器插件配对弹窗状态
|
||||
const [extensionModalStore, setExtensionModalStore] = useState<AdminPddStoreItem | null>(null)
|
||||
const [generatingToken, setGeneratingToken] = useState(false)
|
||||
|
||||
/**
|
||||
* Generate or rotate syncToken for the store
|
||||
* 为当前店铺生成/重置插件配对密钥
|
||||
*/
|
||||
async function handleGenerateSyncToken(shopId: string) {
|
||||
setGeneratingToken(true)
|
||||
try {
|
||||
const res = await generateAdminPddStoreSyncToken(shopId)
|
||||
if (res?.data?.syncToken) {
|
||||
showSuccess('插件配对密钥已生成')
|
||||
if (extensionModalStore && extensionModalStore.shopId === shopId) {
|
||||
setExtensionModalStore({
|
||||
...extensionModalStore,
|
||||
syncToken: res.data.syncToken,
|
||||
})
|
||||
}
|
||||
await loadConfig()
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.message || '生成插件配对密钥失败')
|
||||
} finally {
|
||||
setGeneratingToken(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [])
|
||||
@@ -520,6 +552,11 @@ export function PddPlatformPanel() {
|
||||
{timeDisplay} 校验
|
||||
</Text>
|
||||
)}
|
||||
{record.lastExtensionSyncAt && (
|
||||
<Tag color="cyan" style={{ fontSize: '11px', margin: 0 }}>
|
||||
插件保活: {formatDisplayTime(record.lastExtensionSyncAt)}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -537,6 +574,11 @@ export function PddPlatformPanel() {
|
||||
{timeDisplay} 校验
|
||||
</Text>
|
||||
)}
|
||||
{record.lastExtensionSyncAt && (
|
||||
<Tag color="orange" style={{ fontSize: '11px', margin: 0 }}>
|
||||
插件上次: {formatDisplayTime(record.lastExtensionSyncAt)}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -547,10 +589,19 @@ export function PddPlatformPanel() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 300,
|
||||
width: 360,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={6} wrap={false}>
|
||||
<Tooltip title="配置 Chrome/Edge 浏览器插件自动保活与端到端加密同步">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => setExtensionModalStore(record)}
|
||||
>
|
||||
插件配对
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="通过 Pure TS 0as 引擎实时测试 MMS 商家 Cookie 有效性并持久化校验状态">
|
||||
<Button
|
||||
size="small"
|
||||
@@ -1109,6 +1160,163 @@ export function PddPlatformPanel() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 浏览器插件配对弹窗 */}
|
||||
<Modal
|
||||
title={`浏览器插件配对:${extensionModalStore?.shopName || ''} (${extensionModalStore?.shopId || ''})`}
|
||||
open={Boolean(extensionModalStore)}
|
||||
onCancel={() => setExtensionModalStore(null)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setExtensionModalStore(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={680}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="无感自动保活:配合 Chrome / Edge 扩展程序使用"
|
||||
description="在客服日常登录拼多多后台的浏览器中安装「拼多多 Cookie 自动保活」扩展后,插件会在后台自动捕获新鲜 Cookie 并通过 RSA-OAEP + AES-256-GCM 混合信封加密安全同步,免除每天人工复制抓包与漏单风险。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<Text strong>服务端接口地址 (Server API URL):</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Input
|
||||
readOnly
|
||||
value={window.location.origin}
|
||||
addonAfter={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(window.location.origin)
|
||||
showSuccess('接口地址已复制')
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>店铺专属配对密钥 (Sync Token):</Text>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Input
|
||||
readOnly
|
||||
value={extensionModalStore?.syncToken || '(未生成配对密钥)'}
|
||||
placeholder="点击右侧按钮生成专属密钥"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
color: extensionModalStore?.syncToken ? '#1890ff' : '#8c8c8c',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={generatingToken}
|
||||
onClick={() =>
|
||||
extensionModalStore?.shopId && handleGenerateSyncToken(extensionModalStore.shopId)
|
||||
}
|
||||
>
|
||||
{extensionModalStore?.syncToken ? '重新生成密钥' : '生成配对密钥'}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
||||
* 重新生成密钥后,旧插件配对将失效,需将新密钥填入插件中。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extensionModalStore?.syncToken ? (
|
||||
<Card size="small" style={{ background: '#f8fafc', borderColor: '#e2e8f0' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
一键导入插件配置 (JSON):
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
const cfg = JSON.stringify(
|
||||
{
|
||||
serverUrl: window.location.origin,
|
||||
shopId: extensionModalStore.shopId,
|
||||
shopName: extensionModalStore.shopName,
|
||||
syncToken: extensionModalStore.syncToken,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
void navigator.clipboard.writeText(cfg)
|
||||
showSuccess('插件配置 JSON 已复制,打开插件点击【导入配置】即可!')
|
||||
}}
|
||||
>
|
||||
一键复制完整配置
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
background: '#ffffff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(
|
||||
{
|
||||
serverUrl: window.location.origin,
|
||||
shopId: extensionModalStore.shopId,
|
||||
shopName: extensionModalStore.shopName,
|
||||
syncToken: extensionModalStore.syncToken,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card size="small" title="插件安装指引" style={{ background: '#fafafa' }}>
|
||||
<ol
|
||||
style={{
|
||||
paddingLeft: 20,
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
插件源码已存放在项目目录:
|
||||
<Text code>apps/extensions/pdd-cookie-sync/</Text>
|
||||
</li>
|
||||
<li>
|
||||
在 Chrome / Edge 浏览器打开:<Text code>chrome://extensions</Text>
|
||||
</li>
|
||||
<li>开启右上角【开发者模式】,点击左上角【加载已解压的扩展程序】并选择该目录;</li>
|
||||
<li>点击浏览器工具栏扩展图标,点击【导入配置】并粘贴上述配置,保存即完成绑定!</li>
|
||||
</ol>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AdminPddTestCookieResult,
|
||||
AdminPddSyncResult,
|
||||
AdminPddSyncAllResponse,
|
||||
AdminPddStoreSyncTokenResponse,
|
||||
} from '@/types/admin/platform-config'
|
||||
|
||||
export function fetchAdminPddConfig() {
|
||||
@@ -49,3 +50,9 @@ export function triggerAdminPddSync(
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function generateAdminPddStoreSyncToken(shopId: string) {
|
||||
return apiPost<AdminPddStoreSyncTokenResponse>(
|
||||
`/api/v1/admin/platform-config/pdd/stores/${encodeURIComponent(shopId)}/sync-token`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,4 +85,5 @@ export type {
|
||||
AdminPddTestCookieResult,
|
||||
AdminPddSyncResult,
|
||||
AdminPddSyncAllResponse,
|
||||
AdminPddStoreSyncTokenResponse,
|
||||
} from './pdd'
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface AdminPddStoreItem {
|
||||
cookieValid?: boolean
|
||||
cookieCheckMessage?: string
|
||||
totalOrders?: number
|
||||
syncToken?: string
|
||||
lastExtensionSyncAt?: string
|
||||
}
|
||||
|
||||
export interface AdminPddStoreSyncTokenResponse {
|
||||
shopId: string
|
||||
shopName: string
|
||||
syncToken: string
|
||||
}
|
||||
|
||||
export interface AdminPddConfig {
|
||||
|
||||
Reference in New Issue
Block a user