From 693f4de45a81e236d0599064cbe4a4adc0df0bdf Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 30 Jun 2026 15:57:58 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=95=86=E5=AE=B6=E5=8F=91?= =?UTF-8?q?=E7=A0=81-ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mac-docker.example | 3 + .env.server.example | 3 + apps/backend/src/app.ts | 2 + apps/backend/src/config/defaults.ts | 9 + apps/backend/src/config/env-overrides.ts | 30 ++ apps/backend/src/routes/kuaishou-industry.ts | 138 +++++++ .../platforms/kuaishou-industry/config.ts | 38 ++ .../platforms/kuaishou-industry/crypto.ts | 110 ++++++ .../kuaishou-industry/destroy-code-service.ts | 69 ++++ .../platforms/kuaishou-industry/payload.ts | 194 +++++++++ .../kuaishou-industry/query-code-service.ts | 113 ++++++ .../platforms/kuaishou-industry/response.ts | 90 +++++ .../kuaishou-industry/send-code-service.ts | 216 ++++++++++ apps/backend/src/types/runtime-config.ts | 9 + docs/行业电子凭证/0.基础技术.md | 374 ++++++++++++++++++ docs/行业电子凭证/1.快手通知商家发码.md | 39 ++ .../2.快手通知商家关单或者销毁卡券接口.md | 26 ++ .../3.快手查询商家发码结果接口.md | 68 ++++ 18 files changed, 1531 insertions(+) create mode 100644 apps/backend/src/routes/kuaishou-industry.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/config.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/crypto.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/payload.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/response.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts create mode 100644 docs/行业电子凭证/0.基础技术.md create mode 100644 docs/行业电子凭证/1.快手通知商家发码.md create mode 100644 docs/行业电子凭证/2.快手通知商家关单或者销毁卡券接口.md create mode 100644 docs/行业电子凭证/3.快手查询商家发码结果接口.md diff --git a/.env.mac-docker.example b/.env.mac-docker.example index d12af4db..afce6b92 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -29,6 +29,9 @@ ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","rol KAQUAN91_USER_ID= KAQUAN91_SECRET= +# 快手行业电子凭证 +KUASHOU_INDUSTRY_SIGN_SECRET= + # Cloudtentacles CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc CLOUDTENTACLES_DEVICE_TYPE=1 diff --git a/.env.server.example b/.env.server.example index dd1e4cfe..57b3e7b6 100644 --- a/.env.server.example +++ b/.env.server.example @@ -28,6 +28,9 @@ ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-passwo KAQUAN91_USER_ID= KAQUAN91_SECRET= +# 快手行业电子凭证 +KUASHOU_INDUSTRY_SIGN_SECRET= + # Cloudtentacles CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc CLOUDTENTACLES_DEVICE_TYPE=1 diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 195f5699..d672cba4 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -4,6 +4,7 @@ import process from "node:process"; import adminRouter from "./routes/admin.js"; import claimsRouter from "./routes/claims.js"; +import kuaishouIndustryRouter from "./routes/kuaishou-industry.js"; import open91Router from "./routes/open-91.js"; import { accessLogMiddleware } from "./middleware/access-log.js"; import { createCorsMiddleware } from "./middleware/cors.js"; @@ -90,6 +91,7 @@ export function createApp({ }); app.use("/api/v1/open/91", open91Router); + app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter); app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/admin", adminRouter); diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 07f4f4f2..0bfdacaf 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -44,6 +44,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { timestampToleranceSeconds: 600, cardsEncoding: "aes-256-ecb-base64", }, + kuaishouIndustry: { + appKey: "", + signSecret: "", + provider: "kuaishou-industry", + platform: "kuaishou", + shopId: "kuaishou-industry", + shopName: "快手行业电子凭证", + version: "1", + }, cloudtentacles: { baseUrl: "https://123.207.217.176", timeoutMs: 5000, diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 13dc2498..81dc10bc 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -224,6 +224,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ "cloudtentacles", "deviceType", ]), + stringEnv("KUASHOU_INDUSTRY_APP_KEY", [ + "platforms", + "kuaishouIndustry", + "appKey", + ]), + stringEnv("KUASHOU_INDUSTRY_SIGN_SECRET", [ + "platforms", + "kuaishouIndustry", + "signSecret", + ]), + stringEnv("KUASHOU_INDUSTRY_PROVIDER", [ + "platforms", + "kuaishouIndustry", + "provider", + ]), + stringEnv("KUASHOU_INDUSTRY_SHOP_ID", [ + "platforms", + "kuaishouIndustry", + "shopId", + ]), + stringEnv("KUASHOU_INDUSTRY_SHOP_NAME", [ + "platforms", + "kuaishouIndustry", + "shopName", + ]), + stringEnv("KUASHOU_INDUSTRY_VERSION", [ + "platforms", + "kuaishouIndustry", + "version", + ]), corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), ]; diff --git a/apps/backend/src/routes/kuaishou-industry.ts b/apps/backend/src/routes/kuaishou-industry.ts new file mode 100644 index 00000000..b8645ac9 --- /dev/null +++ b/apps/backend/src/routes/kuaishou-industry.ts @@ -0,0 +1,138 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware } from '../middleware/rate-limit.js' +import { handleSendCode } from '../services/platforms/kuaishou-industry/send-code-service.js' +import { handleDestroyCode } from '../services/platforms/kuaishou-industry/destroy-code-service.js' +import { handleQueryCode } from '../services/platforms/kuaishou-industry/query-code-service.js' +import { buildIndustryErrorResponse } from '../services/platforms/kuaishou-industry/response.js' +import { createRequestId, logIntegration } from '../utils/logger.js' + +const router = Router() + +const industryRateLimit = createRateLimitMiddleware({ + scope: 'kuaishouIndustry', + windowMs: 60_000, + max: 120, + onLimit: (_req, res) => { + res.status(200).json(buildIndustryErrorResponse(4010003, '请求过于频繁,请稍后再试')) + }, +}) + +router.post('/send-code', industryRateLimit, async (req, res) => { + const requestId = createRequestId('ksind') + const startedAt = Date.now() + + logIntegration('[kuaishou-industry/send-code]', '收到快手行业电子凭证发码请求', { + requestId, + method: req.method, + originalUrl: req.originalUrl, + ip: req.ip, + query: req.query, + body: req.body, + }) + + try { + const params = mergeRequestParams(req) + const result = await handleSendCode(params) + logIntegration('[kuaishou-industry/send-code]', '发码处理完成', { + requestId, + durationMs: Date.now() - startedAt, + result, + }) + res.status(200).json(result) + } catch (error) { + const message = error instanceof Error ? error.message : '系统异常' + logIntegration('[kuaishou-industry/send-code]', '发码处理失败', { + requestId, + durationMs: Date.now() - startedAt, + error, + }, { level: 'error' }) + res.status(200).json(buildIndustryErrorResponse(4010003, message)) + } +}) + +router.post('/destroy-code', industryRateLimit, async (req, res) => { + const requestId = createRequestId('ksind') + const startedAt = Date.now() + + logIntegration('[kuaishou-industry/destroy-code]', '收到快手行业电子凭证销毁请求', { + requestId, + method: req.method, + originalUrl: req.originalUrl, + ip: req.ip, + query: req.query, + body: req.body, + }) + + try { + const params = mergeRequestParams(req) + const result = await handleDestroyCode(params) + logIntegration('[kuaishou-industry/destroy-code]', '销毁处理完成', { + requestId, + durationMs: Date.now() - startedAt, + result, + }) + res.status(200).json(result) + } catch (error) { + const message = error instanceof Error ? error.message : '系统异常' + logIntegration('[kuaishou-industry/destroy-code]', '销毁处理失败', { + requestId, + durationMs: Date.now() - startedAt, + error, + }, { level: 'error' }) + res.status(200).json(buildIndustryErrorResponse(4010003, message)) + } +}) + +router.post('/query-code', industryRateLimit, async (req, res) => { + const requestId = createRequestId('ksind') + const startedAt = Date.now() + + logIntegration('[kuaishou-industry/query-code]', '收到快手行业电子凭证查询请求', { + requestId, + method: req.method, + originalUrl: req.originalUrl, + ip: req.ip, + query: req.query, + body: req.body, + }) + + try { + const params = mergeRequestParams(req) + const result = await handleQueryCode(params) + logIntegration('[kuaishou-industry/query-code]', '查询处理完成', { + requestId, + durationMs: Date.now() - startedAt, + result, + }) + res.status(200).json(result) + } catch (error) { + const message = error instanceof Error ? error.message : '系统异常' + logIntegration('[kuaishou-industry/query-code]', '查询处理失败', { + requestId, + durationMs: Date.now() - startedAt, + error, + }, { level: 'error' }) + res.status(200).json(buildIndustryErrorResponse(4010003, message)) + } +}) + +function mergeRequestParams(req: any): Record { + const query = req.query || {} + const body = req.body || {} + const params: Record = { ...query } + + for (const [key, value] of Object.entries(body)) { + if (params[key] === undefined && key !== 'param') { + params[key] = value + } + } + + if (body.param && !params.param) { + params.param = body.param + } + + return params +} + +export default router diff --git a/apps/backend/src/services/platforms/kuaishou-industry/config.ts b/apps/backend/src/services/platforms/kuaishou-industry/config.ts new file mode 100644 index 00000000..c3b94dfe --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/config.ts @@ -0,0 +1,38 @@ +import { runtimeConfig } from '../../../config/runtime.js' +import { createHttpError } from '../../../utils/http.js' +import type { RuntimeConfig } from '../../../types/runtime-config.js' + +export const KUISHOU_INDUSTRY_PROVIDER = 'kuaishou-industry' +export const KUISHOU_INDUSTRY_PLATFORM = 'kuaishou' + +type KuaishouIndustryRuntimeConfig = RuntimeConfig['platforms']['kuaishouIndustry'] + +export function getKuaishouIndustryConfig(overrides: Partial = {}) { + const config = { + ...(runtimeConfig.platforms?.kuaishouIndustry || {}), + ...overrides, + } + + return { + appKey: String(config.appKey || '').trim(), + signSecret: String(config.signSecret || '').trim(), + provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER, + platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM, + shopId: String(config.shopId || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER, + shopName: String(config.shopName || '快手行业电子凭证').trim() || '快手行业电子凭证', + version: String(config.version || '1').trim() || '1', + } +} + +export function assertKuaishouIndustryConfig() { + const config = getKuaishouIndustryConfig() + + if (!config.signSecret) { + throw createHttpError('快手行业电子凭证 signSecret 未配置', { + statusCode: 500, + errorCode: 'kuaishou_industry_missing_sign_secret', + }) + } + + return config +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts b/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts new file mode 100644 index 00000000..a16cef20 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts @@ -0,0 +1,110 @@ +import crypto from 'node:crypto' + +import { createHttpError } from '../../../utils/http.js' +import { logWarn } from '../../../utils/logger.js' +import { assertKuaishouIndustryConfig } from './config.js' + +type JsonObject = Record + +type SignMethod = 'MD5' | 'HMAC_SHA256' + +export function buildKuaishouIndustrySignSource(params: JsonObject = {}, { signSecret } = assertKuaishouIndustryConfig()) { + const entries = Object.entries(params) + .filter(([key]) => key !== 'sign' && key !== 'signSecret') + .sort(([left], [right]) => { + if (left === right) { + return 0 + } + return left < right ? -1 : 1 + }) + + const queryString = entries + .map(([key, value]) => `${key}=${stringifySignValue(value)}`) + .join('&') + + return `${queryString}&signSecret=${signSecret}` +} + +export function signKuaishouIndustryPayload( + params: JsonObject = {}, + signMethod: SignMethod = 'MD5', + config = assertKuaishouIndustryConfig(), +) { + const source = buildKuaishouIndustrySignSource(params, config) + + if (signMethod === 'HMAC_SHA256') { + return hmacSha256Sign(source, config.signSecret) + } + + return crypto + .createHash('md5') + .update(source, 'utf8') + .digest('hex') + .toLowerCase() +} + +export function verifyKuaishouIndustrySignature( + params: JsonObject = {}, + config = assertKuaishouIndustryConfig(), +) { + const signMethod = normalizeSignMethod(params.signMethod) + const source = buildKuaishouIndustrySignSource(params, config) + const expected = signKuaishouIndustryPayload(params, signMethod, config) + const actual = String(params.sign || '').trim() + + if (expected !== actual) { + logWarn('[kuaishou-industry/crypto]', '签名验证失败', { + signMethod, + expected, + actual, + source: source.replace(config.signSecret, '***'), + }) + return false + } + + return true +} + +export function assertKuaishouIndustrySignature(params: JsonObject = {}, config = assertKuaishouIndustryConfig()) { + if (!verifyKuaishouIndustrySignature(params, config)) { + throw createHttpError('签名验证失败', { + statusCode: 400, + errorCode: 'kuaishou_industry_invalid_signature', + }) + } +} + +function hmacSha256Sign(source: string, secret: string) { + return crypto + .createHmac('sha256', secret) + .update(source, 'utf8') + .digest('base64') +} + +function normalizeSignMethod(value: unknown): SignMethod { + const normalized = String(value || '').trim().toUpperCase() + if (normalized === 'HMAC_SHA256' || normalized === 'HMAC-SHA256') { + return 'HMAC_SHA256' + } + return 'MD5' +} + +function stringifySignValue(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + if (value == null) { + return '' + } + + if (typeof value === 'object') { + return JSON.stringify(value, Object.keys(value as Record).sort()) + } + + return String(value) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts new file mode 100644 index 00000000..7a1a5780 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts @@ -0,0 +1,69 @@ +import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' +import { listTasksByOrderId } from '../../../repositories/task-repo.js' +import { updateTask, getTaskById } from '../../../repositories/task-repo.js' +import { normalizeTimestampIso } from '../../../utils/time.js' +import { + KUISHOU_INDUSTRY_PLATFORM, + getKuaishouIndustryConfig, +} from './config.js' +import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload.js' +import { assertKuaishouIndustrySignature } from './crypto.js' +import { + buildIndustrySuccessResponse, + buildIndustryErrorResponse, +} from './response.js' + +type JsonObject = Record + +export async function handleDestroyCode(rawBody: JsonObject = {}) { + const config = getKuaishouIndustryConfig() + + assertKuaishouIndustrySignature(rawBody, config) + + const params = normalizeDestroyCodePayload(rawBody) + assertDestroyCodePayload(params, config) + + const now = normalizeTimestampIso(new Date().toISOString()) + const normalizedOid = params.oid + + const order = await findLatestOrderByPlatformOrderId({ + provider: config.provider, + platform: KUISHOU_INDUSTRY_PLATFORM, + platformOrderId: normalizedOid, + }) + + if (!order) { + return buildIndustrySuccessResponse({ oid: normalizedOid }) + } + + const tasks = await listTasksByOrderId(order.id) + + if (params.etickets.length > 0) { + const targetIds = new Set(params.etickets.map((e) => String(e.id))) + + for (const task of tasks) { + const taskId = String(task.id || '') + if (targetIds.has(taskId)) { + await updateTask(taskId, { + task_status: 'destroyed', + delivery_status: 'destroyed', + result_code: params.reason, + result_message: `销毁原因: ${params.reason}`, + updated_at: now, + }) + } + } + } else { + for (const task of tasks) { + await updateTask(task.id, { + task_status: 'cancelled', + delivery_status: 'cancelled', + result_code: params.reason, + result_message: `订单关闭: ${params.reason}`, + updated_at: now, + }) + } + } + + return buildIndustrySuccessResponse({ oid: normalizedOid }) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/payload.ts b/apps/backend/src/services/platforms/kuaishou-industry/payload.ts new file mode 100644 index 00000000..670cb4c8 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/payload.ts @@ -0,0 +1,194 @@ +import { createHttpError } from '../../../utils/http.js' +import { assertKuaishouIndustryConfig } from './config.js' + +type JsonObject = Record + +export function normalizeIndustryString(value: unknown) { + return String(value || '').trim() +} + +export function normalizeSendCodePayload(raw: JsonObject = {}) { + const param = parseParamField(raw) + return { + appKey: normalizeIndustryString(raw.appkey), + version: normalizeIndustryString(raw.version), + timestamp: normalizeIndustryTimestamp(raw.timestamp), + signMethod: normalizeIndustryString(raw.signMethod || 'MD5'), + sign: normalizeIndustryString(raw.sign), + accessToken: normalizeIndustryString(raw.access_token), + method: normalizeIndustryString(raw.method), + paramRaw: normalizeIndustryString(raw.param), + + oid: normalizeIndustryString(param.oid), + sellerId: normalizeIndustryString(param.sellerId), + num: normalizeIndustryInteger(param.num), + itemId: normalizeIndustryString(param.itemId), + skuId: normalizeIndustryString(param.skuId), + itemTitle: normalizeIndustryString(param.itemTitle), + sendType: normalizeIndustryString(param.sendType) || 'VIRTUAL', + eticketType: normalizeIndustryString(param.eticketType), + token: normalizeIndustryString(param.token), + certExpireType: normalizeIndustryInteger(param.certExpireType), + certStartTime: normalizeIndustryLong(param.certStartTime), + certEndTime: normalizeIndustryLong(param.certEndTime), + certExpDays: normalizeIndustryInteger(param.certExpDays), + certActualStartTime: normalizeIndustryLong(param.certActualStartTime), + certActualEndTime: normalizeIndustryLong(param.certActualEndTime), + expressCode: normalizeIndustryString(param.expressCode), + expressNo: normalizeIndustryString(param.expressNo), + ext: normalizeIndustryString(param.ext), + } +} + +export function normalizeDestroyCodePayload(raw: JsonObject = {}) { + const param = parseParamField(raw) + return { + appKey: normalizeIndustryString(raw.appkey), + version: normalizeIndustryString(raw.version), + timestamp: normalizeIndustryTimestamp(raw.timestamp), + signMethod: normalizeIndustryString(raw.signMethod || 'MD5'), + sign: normalizeIndustryString(raw.sign), + accessToken: normalizeIndustryString(raw.access_token), + method: normalizeIndustryString(raw.method), + paramRaw: normalizeIndustryString(raw.param), + + oid: normalizeIndustryString(param.oid), + reason: normalizeIndustryString(param.reason), + etickets: normalizeDestroyEtickets(param.etickets), + } +} + +export function normalizeQueryCodePayload(raw: JsonObject = {}) { + const param = parseParamField(raw) + return { + appKey: normalizeIndustryString(raw.appkey), + version: normalizeIndustryString(raw.version), + timestamp: normalizeIndustryTimestamp(raw.timestamp), + signMethod: normalizeIndustryString(raw.signMethod || 'MD5'), + sign: normalizeIndustryString(raw.sign), + accessToken: normalizeIndustryString(raw.access_token), + method: normalizeIndustryString(raw.method), + paramRaw: normalizeIndustryString(raw.param), + + oid: normalizeIndustryString(param.oid), + eticketId: normalizeIndustryString(param.eticketId), + sendType: normalizeIndustryString(param.sendType) || 'VIRTUAL', + eticketType: normalizeIndustryString(param.eticketType), + ext: normalizeIndustryString(param.ext), + } +} + +export function assertSendCodePayload(payload: ReturnType, config = assertKuaishouIndustryConfig()) { + if (!payload.oid) { + throw createHttpError('缺少 oid', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_oid', + }) + } + + if (!payload.sendType) { + throw createHttpError('缺少 sendType', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_send_type', + }) + } + + assertCommonPayload(payload, config) +} + +export function assertDestroyCodePayload(payload: ReturnType, config = assertKuaishouIndustryConfig()) { + if (!payload.oid) { + throw createHttpError('缺少 oid', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_oid', + }) + } + + if (!payload.reason) { + throw createHttpError('缺少 reason', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_reason', + }) + } + + assertCommonPayload(payload, config) +} + +export function assertQueryCodePayload(payload: ReturnType, config = assertKuaishouIndustryConfig()) { + if (!payload.oid) { + throw createHttpError('缺少 oid', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_oid', + }) + } + + assertCommonPayload(payload, config) +} + +function assertCommonPayload( + payload: { appKey: string, version: string, timestamp: number, signMethod: string, sign: string, method: string, accessToken: string, paramRaw: string }, + config = assertKuaishouIndustryConfig(), +) { + if (!payload.signMethod) { + throw createHttpError('缺少 signMethod', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_sign_method', + }) + } + + if (!payload.sign) { + throw createHttpError('缺少 sign', { + statusCode: 400, + errorCode: 'kuaishou_industry_missing_sign', + }) + } + + const normalizedMethod = payload.signMethod.toUpperCase() + if (normalizedMethod !== 'MD5' && normalizedMethod !== 'HMAC_SHA256' && normalizedMethod !== 'HMAC-SHA256') { + throw createHttpError(`不支持的签名算法: ${payload.signMethod},仅支持 MD5 或 HMAC_SHA256`, { + statusCode: 400, + errorCode: 'kuaishou_industry_unsupported_sign_method', + }) + } +} + +function parseParamField(raw: JsonObject) { + if (raw.param && typeof raw.param === 'object' && !Array.isArray(raw.param)) { + return raw.param + } + + try { + const parsed = JSON.parse(normalizeIndustryString(raw.param || '{}')) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +function normalizeIndustryInteger(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : 0 +} + +function normalizeIndustryLong(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function normalizeIndustryTimestamp(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function normalizeDestroyEtickets(value: unknown) { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item) => ({ + id: normalizeIndustryString(item?.id), + code: normalizeIndustryString(item?.code), + num: normalizeIndustryInteger(item?.num), + goodsValue: normalizeIndustryLong(item?.goodsValue), + })) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts new file mode 100644 index 00000000..9ba9fac4 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts @@ -0,0 +1,113 @@ +import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' +import { listTasksByOrderId } from '../../../repositories/task-repo.js' +import type { TaskRow } from '../../../types/repository/rows.js' +import { + KUISHOU_INDUSTRY_PLATFORM, + getKuaishouIndustryConfig, +} from './config.js' +import { normalizeQueryCodePayload, assertQueryCodePayload } from './payload.js' +import { assertKuaishouIndustrySignature } from './crypto.js' +import { + buildIndustrySuccessResponse, + buildIndustryErrorResponse, + buildIndustryEticketItem, + buildIndustryQueryCodeData, +} from './response.js' + +type JsonObject = Record + +export async function handleQueryCode(rawBody: JsonObject = {}) { + const config = getKuaishouIndustryConfig() + + assertKuaishouIndustrySignature(rawBody, config) + + const params = normalizeQueryCodePayload(rawBody) + assertQueryCodePayload(params, config) + + const normalizedOid = params.oid + + const order = await findLatestOrderByPlatformOrderId({ + provider: config.provider, + platform: KUISHOU_INDUSTRY_PLATFORM, + platformOrderId: normalizedOid, + }) + + if (!order) { + return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`) + } + + const tasks = await listTasksByOrderId(order.id) + + if (params.eticketId) { + const matched = tasks.find((t) => String(t.id || '') === params.eticketId) + if (!matched) { + return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`) + } + + const eticket = buildEticketFromTask(matched, params.eticketType) + return buildIndustrySuccessResponse( + buildIndustryQueryCodeData({ + oid: normalizedOid, + sendType: params.sendType, + sendNum: 1, + etickets: [eticket], + }), + ) + } + + const etickets = tasks.map((task) => + buildEticketFromTask(task, params.eticketType), + ) + + return buildIndustrySuccessResponse( + buildIndustryQueryCodeData({ + oid: normalizedOid, + sendType: params.sendType, + sendNum: etickets.reduce((sum, e) => sum + (Number(e.num) || 0), 0), + etickets, + }), + ) +} + +function buildEticketFromTask(task: TaskRow, eticketType: string) { + const taskId = String(task.id || '') + const taskStatus = String(task.task_status || '') + const status = mapTaskStatusToEticketStatus(taskStatus) + + let contextJson: JsonObject = {} + try { + contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {}) + } catch { + contextJson = {} + } + + return buildIndustryEticketItem({ + id: taskId, + status, + num: 1, + validStartTime: Number(contextJson.certActualStartTime || 0), + validEndTime: Number(contextJson.certActualEndTime || 0), + eticketType, + consumeDetails: [], + }) +} + +function mapTaskStatusToEticketStatus(taskStatus: string) { + switch (taskStatus) { + case 'destroyed': + case 'cancelled': + return 'DESTROYED' + case 'consumed': + case 'redeemed': + case 'completed': + case 'delivered': + return 'CONSUMED' + case 'unused': + case 'pending_payment': + case 'pending_delivery': + case 'pending_fulfill': + case 'pending_review': + default: + return 'UNUSED' + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/response.ts b/apps/backend/src/services/platforms/kuaishou-industry/response.ts new file mode 100644 index 00000000..a4be8e94 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/response.ts @@ -0,0 +1,90 @@ +type JsonObject = Record + +export function buildIndustrySuccessResponse(data: unknown = null) { + return { + result: 1, + error_msg: '', + data: data || {}, + } +} + +export function buildIndustryErrorResponse(result: number, errorMsg: string) { + return { + result, + error_msg: errorMsg || '系统异常', + data: null as null, + } +} + +export function buildIndustryEticketItem(eticket: { + id: string + code?: string + status?: string + num?: number + validStartTime?: number + validEndTime?: number + consumeDetails?: JsonObject[] + eticketType?: string +}) { + const item: JsonObject = { + id: eticket.id, + status: eticket.status || 'UNUSED', + num: eticket.num || 1, + validStartTime: eticket.validStartTime || 0, + validEndTime: eticket.validEndTime || 0, + } + + if (eticket.code) { + item.code = eticket.code + } + + if (eticket.consumeDetails && eticket.consumeDetails.length > 0) { + item.eticketConsumeDetails = eticket.consumeDetails.map((detail) => ({ + serialNum: detail.serialNum || '', + consumeType: detail.consumeType || '', + consumeTime: detail.consumeTime || 0, + appointmentTime: detail.appointmentTime, + storeName: detail.storeName, + storeAddress: detail.storeAddress, + expressCode: detail.expressCode, + expressName: detail.expressName, + expressNo: detail.expressNo, + reason: detail.reason, + ext: detail.ext, + })) + } + + if (eticket.eticketType) { + item.eticketType = eticket.eticketType + } + + return item +} + +export function buildIndustrySendCodeData(data: { + oid: string + sendType: string + sendNum: number + etickets: JsonObject[] +}) { + return { + oid: data.oid, + sendType: data.sendType, + sendNum: data.sendNum, + etickets: data.etickets, + } +} + +export function buildIndustryQueryCodeData(data: { + oid: string + sendType: string + sendNum: number + etickets: JsonObject[] +}) { + return { + oid: data.oid, + sendType: data.sendType, + sendNum: data.sendNum, + etickets: data.etickets, + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts new file mode 100644 index 00000000..80e38a8d --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts @@ -0,0 +1,216 @@ +import { findLatestOrderByPlatformOrderId, createOrder } from '../../../repositories/order-repo.js' +import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js' +import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js' +import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' +import { normalizeTimestampIso } from '../../../utils/time.js' +import type { TaskRow } from '../../../types/repository/rows.js' +import { + KUISHOU_INDUSTRY_PROVIDER, + KUISHOU_INDUSTRY_PLATFORM, + getKuaishouIndustryConfig, +} from './config.js' +import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js' +import { assertKuaishouIndustrySignature } from './crypto.js' +import { + buildIndustrySuccessResponse, + buildIndustryErrorResponse, + buildIndustryEticketItem, + buildIndustrySendCodeData, +} from './response.js' + +const INDUSTRY_PROFILE_KEY = 'kuaishou-industry' + +type JsonObject = Record + +export async function handleSendCode(rawBody: JsonObject = {}) { + const config = getKuaishouIndustryConfig() + + assertKuaishouIndustrySignature(rawBody, config) + + const params = normalizeSendCodePayload(rawBody) + assertSendCodePayload(params, config) + + const now = normalizeTimestampIso(new Date().toISOString()) + const normalizedOid = params.oid + + let order = await findLatestOrderByPlatformOrderId({ + provider: config.provider, + platform: KUISHOU_INDUSTRY_PLATFORM, + platformOrderId: normalizedOid, + }) + + if (!order) { + order = await createOrder({ + provider: config.provider, + platform: KUISHOU_INDUSTRY_PLATFORM, + shopId: config.shopId, + shopName: config.shopName, + platformOrderId: normalizedOid, + orderStatus: 'paid', + payStatus: 'paid', + buyerId: params.sellerId, + buyerName: '', + receiverContact: '', + totalAmount: '0', + currency: 'CNY', + rawPayloadJson: JSON.stringify(params), + paidAt: now, + createdAt: now, + updatedAt: now, + }) + } + + if (!order) { + return buildIndustryErrorResponse(4010003, '创建订单失败') + } + + const profile = await ensureIndustryProfile(now) + if (!profile) { + return buildIndustryErrorResponse(4010003, '创建履约配置失败') + } + + const existingTasks = await listTasksByOrderId(order.id) + const existingTaskCount = existingTasks.length + const targetTaskCount = params.num > 0 ? params.num : 1 + const tasksToCreate = Math.max(0, targetTaskCount - existingTaskCount) + + if (tasksToCreate > 0) { + const items = [] + for (let i = 0; i < tasksToCreate; i++) { + const unitIndex = existingTaskCount + i + 1 + items.push({ + skuCode: params.itemId || params.skuId || 'kuaishou-industry', + skuName: params.itemTitle || '行业电子凭证', + quantity: 1, + specJson: JSON.stringify({ + itemId: params.itemId, + skuId: params.skuId, + oid: normalizedOid, + }), + itemSnapshotJson: '{}', + createdAt: now, + updatedAt: now, + }) + } + + const orderItems = await replaceOrderItems(order.id, items) + + for (let i = 0; i < tasksToCreate; i++) { + const unitIndex = existingTaskCount + i + 1 + const item = orderItems[i] + + if (!item) { + continue + } + + await createTask({ + orderId: order.id, + orderItemId: item.id, + unitIndex, + taskNo: `${normalizedOid}-${unitIndex}`, + provider: config.provider, + platform: KUISHOU_INDUSTRY_PLATFORM, + shopId: config.shopId, + shopName: config.shopName, + platformOrderId: normalizedOid, + profileId: profile.id, + executorKey: INDUSTRY_PROFILE_KEY, + taskStatus: 'pending_payment', + deliveryStatus: 'pending', + automationMode: 'manual', + requiresClaim: true, + contextJson: JSON.stringify({ + token: params.token, + certExpireType: params.certExpireType, + certStartTime: params.certStartTime, + certEndTime: params.certEndTime, + certExpDays: params.certExpDays, + certActualStartTime: params.certActualStartTime, + certActualEndTime: params.certActualEndTime, + }), + createdAt: now, + updatedAt: now, + }) + } + } + + const allTasks = await listTasksByOrderId(order.id) + const etickets = allTasks.map((task) => + buildEticketFromTask(task, params), + ) + + return buildIndustrySuccessResponse( + buildIndustrySendCodeData({ + oid: normalizedOid, + sendType: params.sendType, + sendNum: etickets.length, + etickets, + }), + ) +} + +async function ensureIndustryProfile(now: string) { + const existing = await getFulfillmentProfileByKey(INDUSTRY_PROFILE_KEY) + + if (existing) { + return existing + } + + return upsertFulfillmentProfile({ + profileKey: INDUSTRY_PROFILE_KEY, + name: '快手行业电子凭证', + executorKey: INDUSTRY_PROFILE_KEY, + requiresClaim: true, + autoDispatch: false, + inventoryStrategy: 'static_pool', + configJson: {}, + createdAt: now, + updatedAt: now, + }) +} + +function buildEticketFromTask(task: TaskRow, params: ReturnType) { + const taskId = String(task.id || '') + const taskStatus = String(task.task_status || '') + const status = mapTaskStatusToEticketStatus(taskStatus) + + let contextJson: JsonObject = {} + try { + contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {}) + } catch { + contextJson = {} + } + + const validStartTime = Number(contextJson.certActualStartTime || params.certActualStartTime || 0) + const validEndTime = Number(contextJson.certActualEndTime || params.certActualEndTime || 0) + + return buildIndustryEticketItem({ + id: taskId, + status, + num: 1, + validStartTime, + validEndTime, + eticketType: params.eticketType, + consumeDetails: [], + }) +} + +function mapTaskStatusToEticketStatus(taskStatus: string) { + switch (taskStatus) { + case 'destroyed': + case 'cancelled': + return 'DESTROYED' + case 'consumed': + case 'redeemed': + case 'completed': + case 'delivered': + return 'CONSUMED' + case 'unused': + case 'pending_payment': + case 'pending_delivery': + case 'pending_fulfill': + case 'pending_review': + default: + return 'UNUSED' + } +} diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index f1b96c2e..ed1f5eef 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -77,6 +77,15 @@ export type RuntimeConfig = { deviceId: string; deviceType: number; }; + kuaishouIndustry: { + appKey: string; + signSecret: string; + provider: string; + platform: string; + shopId: string; + shopName: string; + version: string; + }; }; cors: { allowedOrigins: string[]; diff --git a/docs/行业电子凭证/0.基础技术.md b/docs/行业电子凭证/0.基础技术.md new file mode 100644 index 00000000..c010df6f --- /dev/null +++ b/docs/行业电子凭证/0.基础技术.md @@ -0,0 +1,374 @@ +基础技术 +更新时间:2025-03-26 +阅读数:5713 +本处介绍开放平台的基础协议和通用接入方式 + +1.环境说明 +两套环境相互独立,数据不可相互使用 + +环境 环境地址 说明 +线上环境 https://open.kwaixiaodian.com 线上环境可直接使用 +线下环境 https://gw-merchant-staging.test.gifshow.com 测试环境调用需要提供出口IP,并联系快手对接人添加ip白名单 +2.授权流程 +2.1 如何获取token + 首先需要拿到appkey、appsecret、signSecret信息,线下环境找快手提供,线上环境到“开放平台-控制台-应用中心-应用详情”查看,请求示例如下 +https://gw-merchant-staging.test.gifshow.com/integration/virtual/topup/mobile/order/callback?access_token=ChFvYXV0aC5hY2Nlc3NUb2tlbhJAErnBVtjx5FPdE0AITaSq4xlW6XaTOaV_McGinj-hFivkyxAw1SZ3i28bLEa6xP-C4UuKlRz6uh3lukYRLDcAdRoSeCxQKnQhAPTTL4YL0_NqmqD-IiApwDDKEL1wBW7EbWH0ZRDO3UUNSCxMwkuF7nb57Fn7AygFMAE&appkey=ks683702719562282620&method=integration.virtual.topup.mobile.order.callback¶m=%7B%22orderId%22%3A%22100%22%2C%22businessTime%22%3A%222021-06-08T14%3A16%3A01%2B0800%22%2C%22mobile%22%3A%2215990013607%22%2C%22status%22%3A%22FAILED%22%2C%22amount%22%3A%225000%22%2C%22bizType%22%3A20%7D&signMethod=MD5×tamp=1623132961782&version=1&sign=c091ca3414a9d6429a0fda47c383d202 + +注:如果回调失败需要以指数周期(1m、2m、4m、8m...)重试 + +注意: + +测试环境需要把自己的出口ip给到快手,配置白名单,否则无权限访问。配置对接人:屈国庆,王哲,杨天问 +测试环境和线上环境,是两套token,不可混用。测试token,如下方式获取。线上token通过上面sdk调用,有效期48h。 +Token授权码:参考文档(2.2节 商家开发者角色) +授权一定要用店主的主账号来授权,否则token换取到的sellerId就是子账号的id +线上token验证:https://open.kwaixiaodian.com/commonTool/tokenAuth/accessToken?cateId=0&typeIndex=1,需要快手app扫描登录 +测试环境获取token的步骤(参考文档(2.2节 商家开发者角色)): + +测试环境:优先使用集成中心自己调试 +线上环境:需要按照开放平台的授权文档,通过你的店铺由店主授权(不要用子账号授权),然后用你的redirect_uri来接收code。 +用授权码code换取长时令牌refreshToken以及访问令牌accessToken + 示例:https://gw-merchant-staging.test.gifshow.com/oauth2/access_token?app_id=ks683702719562282620&app_secret=zSCiWmGOk_diMCU9k3zHcg&grant_type=code&code=bab4f0378269db7a1603de9d5a381615d7d2c451ffbb32f8ede78ac8f05b6171f7d4eb8c + +用长时令牌refreshToken刷新访问令牌accessToken + 示例: +https://gw-merchant-staging.test.gifshow.com/oauth2/refresh_token?app_id=ks683702719562282620&app_secret=zSCiWmGOk_diMCU9k3zHcg&grant_type=refresh_token&refresh_token=ChJvYXV0aC5yZWZyZXNoVG9rZW4SkAGpBE83BKP9TeiwbEP1-IIQL2G08s8rU-OETIzfs5wFN2HeZgUaR_mfGjKHjHzfV-sicHG4IZmxVKlxQhdAZIdf01AR51ZtPN7sHY3eeW6RMnx8LPKo92V1yk-GnkXiiPMWpn9Vmhq0dk_U_aKh5Jwg7mMySO8IEDoz-fPk9Rc2rjUK3inqxNd7rqTQ9fz316oaEsFIUEDt4EyD090nGWRnwQ5g3SIgY-ctBM_4jrFXxBA9EF1jMaP6as57lNYIyOZf9Qlqq64oBTAB + +后面就可以一直用第三步来刷新accessToken + +3.签名算法 +3.1 协议 +https协议,支持GET/POST,调用地址、appkey、signSecret由快手提供,返回结果为json字符串。 + +3.2 签名 +官方签名说明 + +快手电商开放平台的所有开放API调用都需要进行加签,服务端会根据请求参数,对签名进行验证,签名不合法的请求将会被拒绝。当前平台支持的签名算法为MD5(signMethod=MD5) + +下面将详细介绍签名流程: + +对所有API系统参数和请求参数(不包括sign参数和byte[]类型的参数),根据参数名称进行字典顺序排序; + +排序前的顺序是:appkey=ks123,version=1,method=open.xxx.xxx,signMethod=MD5,access_token=xxxx,timestamp=1583271919000,param={"title":"短袖", "relItemId":123456, "categoryId":12} + +排序后的顺序是:appkey=ks123¶m={"categoryId":12,"relItemId":123456,"title":"短袖"}&signMethod=MD5×tamp=1583271919000&version=1&signSecret=abc + + + +保留=符号,用&符号将多个参数及其值组装在一起,根据上面的示例得到的排序结果为:access_token=xxx&appkey=ks123&method=open.xxx.xxx¶m={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5×tamp=1583271919000&version=1 + +排序好参数后,在末尾加入signSecret(在应用创建审核通过后由平台分配,在“应用中心-应用列表-应用详情”中可见)进行对应算法的签名计算 + +MD5(access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx¶m={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5×tamp=1583271919000&version=1&signSecret=xxxxxx)=sign + + + +签名加好后,将sign添加到请求参数中,并对param内容进行encode(双引号"和冒号:也需要encode),这里注意请求参数里面是不包括signSecret的,signSecret只是作为加签因子用于签名sign的计算, + +所以千万不要将signSecret当作请求参数传输,请求参数内容见第3点API调用参数说明的表格内容,请求url样例: + +https://open.kwaixiaodian.com/open/xxx/xxx?access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx¶m=%7B%22title%22%3A%22%E7%9F%AD%E8%A2%96%22%2C%20%22relItemId%22%3A123456%2C%20%22categoryId%22%3A12%7D&version=1&signMethod=MD5×tamp=1583271919000&sign=af2d80958e77e17f1d973003b7b7aec2 +说明:param是json对象,需要排序 + +4.API调用 +确认完成了授权流程后进行API的调用测试,内容详情可见《API调用说明》,也可使用平台提供的API测试工具测试 + + + + + +======= +API调用说明 +更新时间:2024-10-10 +阅读数:40538 +1.前期准备 +首先需要入驻快手电商开放平台成为开发者,并在开发者账号下创建应用,详情请看《开放平台入驻指南》 + +2.API调用方式 +2.1 域名信息 +环境 + +域名 + +测试环境 +https://gw-merchant-staging.test.gifshow.com(仅内测,不对外开放) + +生产环境(推荐) + +https://openapi.kwaixiaodian.com + +生产环境(备用) + +https://open.kwaixiaodian.com + +2.2 OAUTH认证 + web端授权方式,商家/子账号员工/开发者可通过授权链接获取用户授权,授权通过后即可通过token调用授权API,详见《授权说明》 + +2.3 SDK下载 + 快手电商开放平台提供了所有线上API和消息的SDK,目前支持java 1.6及以上版本,推荐广大开发者使用。 + +3.API调用参数说明 + 请求url样例:https://openapi.kwaixiaodian.com/open/xxx/xxx/xxx?appkey=ksxxxxx&method=open.xxx.xxx.xxx&version=1¶m=xxxxxxx&access_token=xxxxxxxxxxxxxx×tamp=158888888888&signMethod=MD5&sign=xxxxxx + + 请求Content-type仅支持application/x-www-form-urlencoded,如果是post请求,请将param参数放到body里,防止url 过长导致请求失败 + +url字段 + +是否必须 + +描述 + +https://openapi.kwaixiaodian.com + +是 + +开放平台环境的域名,对应2.1域名信息 + +/open/xxx/xxx/xx + +是 + +请求的API,用/替换名称中的. + +appkey=ksxxxxx + +是 + +平台分配的appkey,即client_id即appId + +method=open.xxx.xxx.xxx + +是 + +请求的API,详见各API名称 + +version=1 + +是 + +请求的API版本号,目前版本都为1 + +param=xxxxxxxx + +是 + +业务参数,详见各API的入参内容 + +access_token=xxxxxxxxxx + +是 + +授权API必填,详见开发指南的授权说明文档 + +timestamp=1583271919000 +是 + +发起请求的Unix时间戳,单位为毫秒 + +signMethod=HMAC_SHA256 +是 + +签名算法,支持HMAC_SHA256和MD5, 推荐使用HMAC_SHA256 + +sign=xxxxxxxx +是 + +API入参的签名计算结果(2020.10.16开始灰度,10.31正式生效) + +4.签名算法说明 +快手电商开放平台的所有开放API调用都需要进行加签,服务端会根据请求参数,对签名进行验证,签名不合法的请求将会被拒绝。目前支持的签名算法有两种:MD5(signMethod=MD5),HMAC_SHA256(signMethod=HMAC_SHA256),下面将以MD5算法为例详细介绍签名流程,使用HMAC_SHA256算法直接替换即可: + +注意:是先进行参数签名计算,然后再对参数进行url encode。如果参数是放在请求body里的,那么url encode是非必须的。 + +对所有API系统参数和请求参数(不包括sign参数和byte[]类型的参数),根据参数名称进行字典顺序排序; + +排序前的顺序是:appkey=ks123,version=1,method=open.xxx.xxx,signMethod=MD5,access_token=xxxx,timestamp=1583271919000,param={"title":"短袖", "relItemId":123456, "categoryId":12} + +排序后的顺序是:access_token=xxxx, appkey=ks123,method=open.xxx.xxx,param={"title":"短袖", "relItemId":123456, "categoryId":12},signMethod=MD5,timestamp=1583271919000,version=1。 + +保留=符号,用&符号将多个参数及其值组装在一起,根据上面的示例得到的排序结果为:access_token=xxx&appkey=ks123&method=open.xxx.xxx¶m={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5×tamp=1583271919000&version=1。 + +排序好参数后,在末尾加入signSecret(在应用创建审核通过后由平台分配,在“应用中心-应用列表-应用详情”中可见)进行对应算法的签名计算 + +如果使用MD5算法,则MD5(access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx¶m={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5×tamp=1583271919000&version=1&signSecret=xxxxxx)=sign;如果使用HMAC_SHA256算法,则HMAC_SHA256(access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx¶m={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=HMAC_SHA256×tamp=1583271919000&version=1&signSecret=xxxxxx)=sign; + +签名加好后,将sign添加到请求参数中,并对param内容进行encode(双引号"和冒号:也需要encode),这里注意请求参数里面是不包括signSecret的,signSecret只是作为加签因子用于签名sign的计算,所以千万不要将signSecret当作请求参数传输,请求参数内容见第3点API调用参数说明的表格内容,请求url样例: + +https://openapi.kwaixiaodian.com/open/xxx/xxx?access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx¶m=%7B%22title%22%3A%22%E7%9F%AD%E8%A2%96%22%2C%20%22relItemId%22%3A123456%2C%20%22categoryId%22%3A12%7D&version=1&signMethod=MD5×tamp=1583271919000&sign=af2d80958e77e17f1d973003b7b7aec2 + + + +//签名计算 +public static String sign(String param, String signSecret, SignMethodEnum signMethod) { + StringBuffer sb = new StringBuffer(); + sb.append(param).append("&").append(SIGN_SECRET).append("=").append(signSecret); + String inputStr = sb.toString(); + switch (signMethod) { + //HMAC_SHA256算法 + case HMAC_SHA256: + return HMACSHA256SignUtils.sign(inputStr, signSecret); + //默认md5算法 + case MD5: + default: + return org.apache.commons.codec.digest.DigestUtils.md5Hex(inputStr); + } +} + +// 加签方法 +public static String sign(Map requestParamMap, String signSecret, SignMethodEnum signMethod) { + return sign(getSignParam(requestParamMap), signSecret, signMethod); +} +public static String getSignParam(Map requestParamMap) { + String method = checkAndGetParam(requestParamMap, METHOD); + String appKey = checkAndGetParam(requestParamMap, APPKEY); + String accessToken = checkAndGetParam(requestParamMap, ACCESS_TOKEN); + String version = requestParamMap.get(VERSION); + String signMethod = requestParamMap.get(SIGN_METHOD); + String timestamp = requestParamMap.get(TIMESTAMP); + String param = requestParamMap.get(PARAM); + Map signMap = new HashMap(); + // 必传参数 + signMap.put(METHOD, method); + signMap.put(APPKEY, appKey); + signMap.put(ACCESS_TOKEN, accessToken); + //可选参数 + if (signMethod != null) { + signMap.put(SIGN_METHOD, signMethod); + } + if (version != null) { + signMap.put(VERSION, version); + } + if (timestamp != null) { + signMap.put(TIMESTAMP, timestamp); + } + if (param != null) { + signMap.put(PARAM, param); + } + String signParam =sortAndJoin(signMap); + return signParam; +} +public static String checkAndGetParam(Map paramMap, String paramKey) { + String value = paramMap.get(paramKey); + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException(paramKey + " not exist"); + } + return value; +} +// 排序 +public static String sortAndJoin(Map params) { + TreeMap paramsTreeMap = new TreeMap(); + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() == null) { + continue; + } + paramsTreeMap.put(entry.getKey(), entry.getValue()); + } + String signCalc = ""; + for (Map.Entry entry : paramsTreeMap.entrySet()) { + signCalc = String.format("%s%s=%s&", signCalc, entry.getKey(), entry.getValue(), "&"); + } + if (signCalc.length() > 0) { + signCalc = signCalc.substring(0, signCalc.length() - 1); + } + return signCalc; +} + +private class HMACSHA256SignUtils { + protected static final Logger logger = Logger.getLogger(HMACSHA256SignUtils.class.getName()); + + /** + * hmac_sha256取hash Base64编码 + */ + public static String sign(String params, String secret) { + String result = ""; + try { + Mac sha256HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "HmacSHA256"); + sha256HMAC.init(secretKey); + byte[] sha256HMACBytes = sha256HMAC.doFinal(params.getBytes()); + String hash = Base64.encodeBase64String(sha256HMACBytes); + return hash; + } catch (Exception e) { + logger.warning("HMACSHA256SignUtils sign failed, params=" + params + ", error=" + e.getMessage()); + } + return result; + } +} + + +5.权限组列表 + 权限组用于控制APP调用API和接受消息的权限范围,只有当APP拥有该API所属的权限组才可以调用API,只有当APP拥有该消息所属的权限组才可以消费消息,列表中说明了现有的权限组以及对应的权限组的API和消息。快手电商开放平台会根据开发者创建的应用类型授予默认的权限组,若开发者需要申请额外的权限组,请发邮件到open@kuaishou.com并说明理由。 + +Scope + +描述 + +备注 + +user_base + +授权之后的默认权限 + +所有应用默认拥有此权限组 + +user_info + +用户基本信息 + +所有应用默认拥有此权限组 + +merchant_user + +商家用户信息 + +用户API权限 + +merchant_item + +读取或更新店铺的商品数据 + +商品API和商品消息权限 + +merchant_order + +读取或更新店铺的订单信息 + +订单API和订单消息权限 + +merchant_refund + +读取或更新店铺的售后信息 + +退款单API和退款单消息权限 + +merchant_distribution + +读取或更新分销信息 + +分销API权限 + +merchant_logistics + +读取或更新物流信息 + +物流API权限 + +merchant_servicemarket + +读取应用在服务市场的信息 + +服务市场API权限 + +merchant_comment + +读取或更新订单评价信息 + +评价API权限 + +merchant_cs + +读取或更新店铺的客服信息 + +客服API权限 + + diff --git a/docs/行业电子凭证/1.快手通知商家发码.md b/docs/行业电子凭证/1.快手通知商家发码.md new file mode 100644 index 00000000..1e3f504e --- /dev/null +++ b/docs/行业电子凭证/1.快手通知商家发码.md @@ -0,0 +1,39 @@ +公共入参 +参数名 类型 必须 详情描述 +appkey String 是 平台分配的appId +version String 是 请求的API版本号,目前版本为1 +timestamp Long 是 发起请求的Unix时间戳,单位为毫秒 + + +服务入参 +参数名 类型 必须 详情描述 +oid String 是 快手订单号,示例:2319200000001216 +sellerId String 是 卖家的用户id,示例:2174425348 +num Integer 是 发码数量,对应订单的下单数量,示例:1。必须等于商家发码回调的卡券数量 +itemId String 是 快手体系内的商品id,示例:20842040339391 +skuId String 否 快手体系商品SKU ID,示例:87457167264391 +itemTitle String 否 快手体系内的商品标题 +sendType String 是 券码类型:虚拟卡-VIRTUAL,实体卡-DELIVERY。默认传VIRTUAL +eticketType String 否 电子凭证类型,跟商家想要入驻的类目相关联,示例: +DINING_OPEN_TICKET +token String 是 订单维度授权token,发码回调时需要带回来。示例:454e02cb-5ccf-4554-9217-e6b997477fd8 +certExpireType Integer 是 商家发品时配置的时间类型,快照,商家用来计算卡券有效期的起止时间。示例: +FIXED_START_END("固定起止时间", 1), +FIXED_END("购买成功后固定结束时间", 2), +FIXED_PERIOD_DAY("购买成功后固定有效天数", 3); +certStartTime Long 否 商家发品时配置的有效期:起始时间,单位毫秒时间戳。示例:1689060872478 +certEndTime Long 否 商家发品时配置的有效期:结束时间,单位毫秒时间戳。示例:1689031322739 +certExpDays Integer 否 商家发品时配置的超时天数,单位天(不支持周、月等其他单位)。当certExpireType=3生效,示例:30(天) +certActualStartTime Long 是 快手依据商品配置计算好的实际有效期开始时间,可用于商家校验 +certActualEndTime Long 是 快手依据商品配置计算好的实际有效期结束时间,可用于商家校验 +expressCode String 否 仅实体卡场景有效,实体卡物流公司 +expressNo String 否 仅实体卡场景有效,实体卡快递单号 +ext String 否 扩展信息 + + +服务出参 +参数名 类型 详情描述 +result Integer 返回码,必传,1-成功、4010003-系统异常,必须按照错误码规范返回。「错误码规范」请参考对接文档 +error_msg String 错误信息,非必传,当result != 1时必须返回明确且清晰的错误信息。禁止返回「null」、「无法排查的信息」、「error」等 +data Data 业务结果,非必传,当result=1时必须返回data +orderNo String 供应商的订单号 diff --git a/docs/行业电子凭证/2.快手通知商家关单或者销毁卡券接口.md b/docs/行业电子凭证/2.快手通知商家关单或者销毁卡券接口.md new file mode 100644 index 00000000..40f6c070 --- /dev/null +++ b/docs/行业电子凭证/2.快手通知商家关单或者销毁卡券接口.md @@ -0,0 +1,26 @@ +公共入参 +参数名 类型 必须 详情描述 +appkey String 是 平台分配的appId +version String 是 请求的API版本号,目前版本为1 +timestamp Long 是 发起请求的Unix时间戳,单位为毫秒 + + +服务入参 +参数名 类型 必须 详情描述 +oid String 是 快手订单号,示例:2319100000001246 +reason String 是 销毁原因,示例: +ETICKET_EXPIRED("凭证已过期"), +USER_APPLY_REFUND("用户申请退款"), +SUPPLY_DESTROY("供应商作废"), +SYS_ADMIN_DESTROY("admin后台作废") +etickets List 否 需要销毁的电子凭证列表,非必填。当没有电子凭证时,表示关闭订单 +id String 是 卡号 +code String 否 实体卡号,大闸蟹业务专用 +num Integer 是 销毁次数,默认1 +goodsValue Long 是 销毁总货值 + + +服务出参 +参数名 类型 详情描述 +result Integer 返回码,必传,1-销毁成功。如果订单不存在或卡券不存在,请返回销毁成功。其他错误,请参考「错误码规范」。 +error_msg String 错误信息,非必传,当result != 1时必须返回明确且清晰的错误信息。禁止返回「null」、「无法排查的信息」、「error」等 \ No newline at end of file diff --git a/docs/行业电子凭证/3.快手查询商家发码结果接口.md b/docs/行业电子凭证/3.快手查询商家发码结果接口.md new file mode 100644 index 00000000..3547e64f --- /dev/null +++ b/docs/行业电子凭证/3.快手查询商家发码结果接口.md @@ -0,0 +1,68 @@ +公共入参 +参数名 类型 必须 详情描述 +appkey String 是 平台分配的appId +version String 是 请求的API版本号,目前版本为1 +timestamp Long 是 发起请求的Unix时间戳,单位为毫秒 + + +服务入参 +参数名 类型 必须 详情描述 +oid String 是 快手订单号,示例:2319100000003246 +eticketId String 否 电子凭证id,非必传 +sendType String 是 卡券类型:虚拟卡-VIRTUAL (默认)实体卡-DELIVERY +eticketType String 否 业务类型:咨询快手开发同学,示例:DINING_OPEN_TICKET +ext String 否 额外字段,供商家使用 + + +服务出参 +参数名 类型 详情描述 +result Integer 返回码,必传,1-成功、4012002-订单不存在、4012005-卡券不存在,必须按照错误码规范返回。「错误码规范」请参考对接文档。 +errorMsg String 错误信息,非必传,当result != 1时必须返回明确且清晰的错误信息。禁止返回「null」、「无法排查的信息」、「error」等 +data EticketsDetail 业务结果,非必传,当result=1时必须返回data +oid String 必传 +快手订单号。示例:2319200000001216 +sendType String 必传 +卡券类型:虚拟卡-VIRTUAL (默认)实体卡-DELIVERY +sendNum Integer 必传 +已发货数量,必须等于etickets列表的num之和 +etickets List 必传 +商家发码的卡券列表 +id String 必传 +卡号 +code String 非必传,实体卡号,大闸蟹业务专用 +status String 必传 +未使用:UNUSED; +已消费(至少消费1次):CONSUMED; +已销毁:DESTROYED; +num Integer 必传 +数量,示例:1 +validStartTime Long 必传 +有效期:开始时间,毫秒时间戳 +validEndTime Long 必传 +有效期:结束时间,毫秒时间戳 +eticketConsumeDetails List 非必传 +核销拓展信息 +serialNum String 必传 +核销流水号 +consumeType String 必传 +核销类型 +consumeTime Long 必传 +核销时间 +appointmentTime Long 非必传 +预约时间 +storeName String 非必传 +门店名称 +storeAddress String 非必传 +门店地址 +expressCode String 非必传 +物流编号 +expressName String 非必传 +物流名称 +expressNo String 非必传 +单 +reason String 非必传 +销毁原因 +ext String 非必传 +拓展字段 +eticketType String 非必传 +电子凭证类型,跟商家想要入驻的类目相关联,示例: DINING_OPEN_TICKET \ No newline at end of file