diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index edb78006..f50e8ed9 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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) diff --git a/apps/backend/src/middleware/cors.ts b/apps/backend/src/middleware/cors.ts index 42ab8d3b..7c8867c7 100644 --- a/apps/backend/src/middleware/cors.ts +++ b/apps/backend/src/middleware/cors.ts @@ -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') diff --git a/apps/backend/src/routes/admin/platform-config/pdd.ts b/apps/backend/src/routes/admin/platform-config/pdd.ts index 896628ea..edddf364 100644 --- a/apps/backend/src/routes/admin/platform-config/pdd.ts +++ b/apps/backend/src/routes/admin/platform-config/pdd.ts @@ -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. diff --git a/apps/backend/src/routes/pdd-extension.test.ts b/apps/backend/src/routes/pdd-extension.test.ts new file mode 100644 index 00000000..2d1dd520 --- /dev/null +++ b/apps/backend/src/routes/pdd-extension.test.ts @@ -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((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((resolve) => server.close(() => resolve())) + } +}) diff --git a/apps/backend/src/routes/pdd-extension.ts b/apps/backend/src/routes/pdd-extension.ts new file mode 100644 index 00000000..66ee1b3a --- /dev/null +++ b/apps/backend/src/routes/pdd-extension.ts @@ -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 diff --git a/apps/backend/src/services/platforms/pdd/pdd-crypto-service.test.ts b/apps/backend/src/services/platforms/pdd/pdd-crypto-service.test.ts new file mode 100644 index 00000000..cb95401b --- /dev/null +++ b/apps/backend/src/services/platforms/pdd/pdd-crypto-service.test.ts @@ -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 }), + /数据解密或完整性认证失败/, + ) +}) diff --git a/apps/backend/src/services/platforms/pdd/pdd-crypto-service.ts b/apps/backend/src/services/platforms/pdd/pdd-crypto-service.ts new file mode 100644 index 00000000..055af7fa --- /dev/null +++ b/apps/backend/src/services/platforms/pdd/pdd-crypto-service.ts @@ -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() +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 + 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, + 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'), + } +} diff --git a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts index 16da3448..d8cf5216 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts @@ -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).out_sku_sn, '安卓血宴3钻') + assert.equal((item.spec as Record).outSkuSn, '安卓血宴3钻') + assert.equal((item.spec as Record).goodsName, '和平精英苏尔南手枪皮肤') + assert.equal((item.spec as Record).spec, '暗潮1钻') +}) diff --git a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts index 9f1aaad3..aaedc8c9 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts @@ -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, }, diff --git a/apps/backend/src/services/platforms/pdd/pdd-source-config-service.ts b/apps/backend/src/services/platforms/pdd/pdd-source-config-service.ts index 5f322024..3487bbee 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-source-config-service.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-source-config-service.ts @@ -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 return config } +/** + * Generate a cryptographically secure sync token for a store. + * 为指定店铺生成专属浏览器插件配对同步密钥。 + */ +export async function generatePddStoreSyncToken(shopId: unknown): Promise { + 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 { + 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, } } diff --git a/apps/backend/src/services/platforms/pdd/pdd-sync-service.test.ts b/apps/backend/src/services/platforms/pdd/pdd-sync-service.test.ts index 1c08e8c8..ed6f35ba 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-sync-service.test.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-sync-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/platforms/pdd/pdd-sync-service.ts b/apps/backend/src/services/platforms/pdd/pdd-sync-service.ts index ae87ef3e..43333a80 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-sync-service.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-sync-service.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 19dd7313..fb3f47c6 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -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) { diff --git a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts index 612de418..73433a7d 100644 --- a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index e027e295..b16ce00b 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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' })), diff --git a/apps/extensions/pdd-cookie-sync/README.md b/apps/extensions/pdd-cookie-sync/README.md new file mode 100644 index 00000000..fd05f692 --- /dev/null +++ b/apps/extensions/pdd-cookie-sync/README.md @@ -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**: + - 代表当前浏览器未登录拼多多商家后台,只需在网页中正常登录即可自动恢复。 diff --git a/apps/extensions/pdd-cookie-sync/background.js b/apps/extensions/pdd-cookie-sync/background.js new file mode 100644 index 00000000..bcb43ca0 --- /dev/null +++ b/apps/extensions/pdd-cookie-sync/background.js @@ -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 + } +}) diff --git a/apps/extensions/pdd-cookie-sync/crypto.js b/apps/extensions/pdd-cookie-sync/crypto.js new file mode 100644 index 00000000..a0bdfe8e --- /dev/null +++ b/apps/extensions/pdd-cookie-sync/crypto.js @@ -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 +} diff --git a/apps/extensions/pdd-cookie-sync/icon.png b/apps/extensions/pdd-cookie-sync/icon.png new file mode 100644 index 00000000..92081b3f Binary files /dev/null and b/apps/extensions/pdd-cookie-sync/icon.png differ diff --git a/apps/extensions/pdd-cookie-sync/manifest.json b/apps/extensions/pdd-cookie-sync/manifest.json new file mode 100644 index 00000000..a0ec6384 --- /dev/null +++ b/apps/extensions/pdd-cookie-sync/manifest.json @@ -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" + } +} diff --git a/apps/extensions/pdd-cookie-sync/popup.html b/apps/extensions/pdd-cookie-sync/popup.html new file mode 100644 index 00000000..543606eb --- /dev/null +++ b/apps/extensions/pdd-cookie-sync/popup.html @@ -0,0 +1,216 @@ + + + + + + 拼多多 Cookie 自动保活 + + + +
+
+ 拼多多 Cookie 保活 +
+
未检测
+
+ + +
+
+ MMS 登录状态: + 检测中... +
+
+ 绑定店铺: + 未配置 +
+
+ 上次同步: + 未同步 +
+ +
+
+ + + + + +
+
+ 连接配置 + 一键导入配置 JSON +
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ 🛡️ RSA-OAEP + AES-256-GCM 端到端信封加密 +
+ + + + diff --git a/apps/extensions/pdd-cookie-sync/popup.js b/apps/extensions/pdd-cookie-sync/popup.js new file mode 100644 index 00000000..b9e4d69e --- /dev/null +++ b/apps/extensions/pdd-cookie-sync/popup.js @@ -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) +}) diff --git a/apps/frontend/src/pages/admin/platform/PddPlatformPanel.tsx b/apps/frontend/src/pages/admin/platform/PddPlatformPanel.tsx index e6880319..c0f3130a 100644 --- a/apps/frontend/src/pages/admin/platform/PddPlatformPanel.tsx +++ b/apps/frontend/src/pages/admin/platform/PddPlatformPanel.tsx @@ -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(null) + // Extension Pairing Modal State / 浏览器插件配对弹窗状态 + const [extensionModalStore, setExtensionModalStore] = useState(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} 校验 )} + {record.lastExtensionSyncAt && ( + + 插件保活: {formatDisplayTime(record.lastExtensionSyncAt)} + + )} ) } @@ -537,6 +574,11 @@ export function PddPlatformPanel() { {timeDisplay} 校验 )} + {record.lastExtensionSyncAt && ( + + 插件上次: {formatDisplayTime(record.lastExtensionSyncAt)} + + )} ) } @@ -547,10 +589,19 @@ export function PddPlatformPanel() { { title: '操作', key: 'action', - width: 300, + width: 360, fixed: 'right', render: (_, record) => ( + + + , + ]} + width={680} + > + + +
+
+ 服务端接口地址 (Server API URL): +
+ } + onClick={() => { + void navigator.clipboard.writeText(window.location.origin) + showSuccess('接口地址已复制') + }} + > + 复制 + + } + /> +
+
+ +
+ 店铺专属配对密钥 (Sync Token): +
+ + +
+
+ * 重新生成密钥后,旧插件配对将失效,需将新密钥填入插件中。 +
+
+ + {extensionModalStore?.syncToken ? ( + +
+ + 一键导入插件配置 (JSON): + + +
+
+                {JSON.stringify(
+                  {
+                    serverUrl: window.location.origin,
+                    shopId: extensionModalStore.shopId,
+                    shopName: extensionModalStore.shopName,
+                    syncToken: extensionModalStore.syncToken,
+                  },
+                  null,
+                  2,
+                )}
+              
+
+ ) : null} + + +
    +
  1. + 插件源码已存放在项目目录: + apps/extensions/pdd-cookie-sync/ +
  2. +
  3. + 在 Chrome / Edge 浏览器打开:chrome://extensions +
  4. +
  5. 开启右上角【开发者模式】,点击左上角【加载已解压的扩展程序】并选择该目录;
  6. +
  7. 点击浏览器工具栏扩展图标,点击【导入配置】并粘贴上述配置,保存即完成绑定!
  8. +
+
+
+ ) } diff --git a/apps/frontend/src/services/admin/platform-config/pdd.ts b/apps/frontend/src/services/admin/platform-config/pdd.ts index 0c2fd548..a594209e 100644 --- a/apps/frontend/src/services/admin/platform-config/pdd.ts +++ b/apps/frontend/src/services/admin/platform-config/pdd.ts @@ -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( + `/api/v1/admin/platform-config/pdd/stores/${encodeURIComponent(shopId)}/sync-token`, + ) +} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 10b902ef..9809b9aa 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -85,4 +85,5 @@ export type { AdminPddTestCookieResult, AdminPddSyncResult, AdminPddSyncAllResponse, + AdminPddStoreSyncTokenResponse, } from './pdd' diff --git a/apps/frontend/src/types/admin/platform-config/pdd.ts b/apps/frontend/src/types/admin/platform-config/pdd.ts index ab964dd4..faacaab9 100644 --- a/apps/frontend/src/types/admin/platform-config/pdd.ts +++ b/apps/frontend/src/types/admin/platform-config/pdd.ts @@ -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 {