通知商家发码-ok
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<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
|
||||
Reference in New Issue
Block a user