通知商家发码-ok

This commit is contained in:
yml2213
2026-06-30 15:57:58 +08:00
parent f58ec9a504
commit 693f4de45a
18 changed files with 1531 additions and 0 deletions
+3
View File
@@ -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
+3
View File
@@ -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
+2
View File
@@ -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);
+9
View File
@@ -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,
+30
View File
@@ -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"]),
];
@@ -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<string, any> {
const query = req.query || {}
const body = req.body || {}
const params: Record<string, any> = { ...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
@@ -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<KuaishouIndustryRuntimeConfig> = {}) {
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
}
@@ -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<string, any>
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<string, unknown>).sort())
}
return String(value)
}
@@ -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<string, any>
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 })
}
@@ -0,0 +1,194 @@
import { createHttpError } from '../../../utils/http.js'
import { assertKuaishouIndustryConfig } from './config.js'
type JsonObject = Record<string, any>
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<typeof normalizeSendCodePayload>, 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<typeof normalizeDestroyCodePayload>, 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<typeof normalizeQueryCodePayload>, 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),
}))
}
@@ -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<string, any>
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'
}
}
@@ -0,0 +1,90 @@
type JsonObject = Record<string, any>
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,
}
}
@@ -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<string, any>
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<typeof normalizeSendCodePayload>) {
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'
}
}
+9
View File
@@ -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[];
+374
View File
@@ -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&param=%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&timestamp=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=ks123version=1method=open.xxx.xxxsignMethod=MD5access_token=xxxxtimestamp=1583271919000param={"title":"短袖", "relItemId":123456, "categoryId":12}
排序后的顺序是:appkey=ks123&param={"categoryId":12,"relItemId":123456,"title":"短袖"}&signMethod=MD5&timestamp=1583271919000&version=1&signSecret=abc
保留=符号,用&符号将多个参数及其值组装在一起,根据上面的示例得到的排序结果为:access_token=xxx&appkey=ks123&method=open.xxx.xxx&param={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5&timestamp=1583271919000&version=1
排序好参数后,在末尾加入signSecret(在应用创建审核通过后由平台分配,在“应用中心-应用列表-应用详情”中可见)进行对应算法的签名计算
MD5(access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx&param={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5&timestamp=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&param=%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&timestamp=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&param=xxxxxxx&access_token=xxxxxxxxxxxxxx&timestamp=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_SHA256signMethod=HMAC_SHA256),下面将以MD5算法为例详细介绍签名流程,使用HMAC_SHA256算法直接替换即可:
注意:是先进行参数签名计算,然后再对参数进行url encode。如果参数是放在请求body里的,那么url encode是非必须的。
对所有API系统参数和请求参数(不包括sign参数和byte[]类型的参数),根据参数名称进行字典顺序排序;
排序前的顺序是:appkey=ks123version=1method=open.xxx.xxxsignMethod=MD5access_token=xxxxtimestamp=1583271919000param={"title":"短袖", "relItemId":123456, "categoryId":12}
排序后的顺序是:access_token=xxxx, appkey=ks123method=open.xxx.xxxparam={"title":"短袖", "relItemId":123456, "categoryId":12}signMethod=MD5timestamp=1583271919000version=1。
保留=符号,用&符号将多个参数及其值组装在一起,根据上面的示例得到的排序结果为:access_token=xxx&appkey=ks123&method=open.xxx.xxx&param={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5&timestamp=1583271919000&version=1。
排序好参数后,在末尾加入signSecret(在应用创建审核通过后由平台分配,在“应用中心-应用列表-应用详情”中可见)进行对应算法的签名计算
如果使用MD5算法,则MD5(access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx&param={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=MD5&timestamp=1583271919000&version=1&signSecret=xxxxxx)=sign;如果使用HMAC_SHA256算法,则HMAC_SHA256(access_token=xxx&appkey=ks123&method=open.xxx.xxx.xxx&param={"title":"短袖", "relItemId":123456, "categoryId":12}&signMethod=HMAC_SHA256&timestamp=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&param=%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&timestamp=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<String, String> requestParamMap, String signSecret, SignMethodEnum signMethod) {
return sign(getSignParam(requestParamMap), signSecret, signMethod);
}
public static String getSignParam(Map<String, String> 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<String, String> signMap = new HashMap<String, String>();
// 必传参数
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<String, String> 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<String, String> params) {
TreeMap<String, String> paramsTreeMap = new TreeMap();
for (Map.Entry<String, String> entry : params.entrySet()) {
if (entry.getValue() == null) {
continue;
}
paramsTreeMap.put(entry.getKey(), entry.getValue());
}
String signCalc = "";
for (Map.Entry<String, String> 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权限
@@ -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 供应商的订单号
@@ -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<DestoryEticket> 否 需要销毁的电子凭证列表,非必填。当没有电子凭证时,表示关闭订单
id String 是 卡号
code String 否 实体卡号,大闸蟹业务专用
num Integer 是 销毁次数,默认1
goodsValue Long 是 销毁总货值
服务出参
参数名 类型 详情描述
result Integer 返回码,必传,1-销毁成功。如果订单不存在或卡券不存在,请返回销毁成功。其他错误,请参考「错误码规范」。
error_msg String 错误信息,非必传,当result != 1时必须返回明确且清晰的错误信息。禁止返回「null」、「无法排查的信息」、「error」等
@@ -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<ETicket> 必传
商家发码的卡券列表
id String 必传
卡号
code String 非必传,实体卡号,大闸蟹业务专用
status String 必传
未使用:UNUSED
已消费(至少消费1次)CONSUMED
已销毁:DESTROYED
num Integer 必传
数量,示例:1
validStartTime Long 必传
有效期:开始时间,毫秒时间戳
validEndTime Long 必传
有效期:结束时间,毫秒时间戳
eticketConsumeDetails List<EticketConsumeExt> 非必传
核销拓展信息
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