增加行业凭证后台 token 配置

This commit is contained in:
yml2213
2026-07-07 23:00:24 +08:00
parent f6cbd7993f
commit 81bfd8b078
24 changed files with 1315 additions and 32 deletions
-1
View File
@@ -33,7 +33,6 @@ KUASHOU_INDUSTRY_APP_KEY=
KUASHOU_INDUSTRY_APP_SECRET= KUASHOU_INDUSTRY_APP_SECRET=
KUASHOU_INDUSTRY_SIGN_SECRET= KUASHOU_INDUSTRY_SIGN_SECRET=
KUASHOU_INDUSTRY_MESSAGE_SECRET= KUASHOU_INDUSTRY_MESSAGE_SECRET=
KUASHOU_INDUSTRY_ACCESS_TOKEN=
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=false KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=false
# Cloudtentacles # Cloudtentacles
-1
View File
@@ -49,7 +49,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
appSecret: "", appSecret: "",
signSecret: "", signSecret: "",
messageSecret: "", messageSecret: "",
accessToken: "",
sendCallbackEnabled: false, sendCallbackEnabled: false,
provider: "kuaishou-industry", provider: "kuaishou-industry",
platform: "kuaishou", platform: "kuaishou",
-5
View File
@@ -246,11 +246,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
"kuaishouIndustry", "kuaishouIndustry",
"messageSecret", "messageSecret",
]), ]),
stringEnv("KUASHOU_INDUSTRY_ACCESS_TOKEN", [
"platforms",
"kuaishouIndustry",
"accessToken",
]),
booleanEnv("KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED", [ booleanEnv("KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED", [
"platforms", "platforms",
"kuaishouIndustry", "kuaishouIndustry",
@@ -2,6 +2,7 @@ import { Router } from "express";
import { requireAdminRoles } from "./session.js"; import { requireAdminRoles } from "./session.js";
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
import kuaishouIndustryRouter from "./platform-config/kuaishou-industry.js";
import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js"; import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js";
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
import ninetyoneRouter from "./platform-config/ninetyone.js"; import ninetyoneRouter from "./platform-config/ninetyone.js";
@@ -11,6 +12,7 @@ const router = Router();
router.use("/platform-config", requireAdminRoles(["admin"])); router.use("/platform-config", requireAdminRoles(["admin"]));
router.use("/platform-config", notificationsRouter); router.use("/platform-config", notificationsRouter);
router.use("/platform-config", kuaishouIndustryRouter);
router.use("/platform-config", kuaishouEticketRouter); router.use("/platform-config", kuaishouEticketRouter);
router.use("/platform-config", kuaishouFeifeiRouter); router.use("/platform-config", kuaishouFeifeiRouter);
router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", ninetyoneRouter);
@@ -0,0 +1,78 @@
import { Router } from 'express'
import {
getAdminKuaishouIndustrySourceConfig,
refreshAdminKuaishouIndustryAccessToken,
updateAdminKuaishouIndustrySourceConfig,
} from '../../../services/admin/platform-config/kuaishou-industry-service.js'
import type {
AdminKuaishouIndustrySourceConfigRouteBody,
} from '../../../types/admin/route-inputs.js'
import { createJsonHandler } from '../session.js'
import type { JsonRecord } from '../../../types/json.js'
const router = Router()
router.get(
'/kuaishou-industry-source',
createJsonHandler(() => getAdminKuaishouIndustrySourceConfig(), {
successMessage: 'ok',
errorMessage: '读取快手行业电子凭证配置失败',
scope: '[admin/platform-config/kuaishou-industry-source]',
}),
)
router.post(
'/kuaishou-industry-source',
createJsonHandler(
(req) =>
updateAdminKuaishouIndustrySourceConfig(
req.body as AdminKuaishouIndustrySourceConfigRouteBody,
),
{
successMessage: '快手行业电子凭证配置已保存',
errorMessage: '保存快手行业电子凭证配置失败',
scope: '[admin/platform-config/kuaishou-industry-source]',
audit: (_req, data) => {
const result = data as JsonRecord
return {
action: 'platform_kuaishou_industry_source_updated',
targetType: 'platform_config',
targetId: 'kuaishou_industry_source',
data: {
filePath: String(result.filePath || '').trim(),
enabled: Boolean(result.source?.enabled),
sendCallbackEnabled: Boolean(result.source?.sendCallbackEnabled),
hasAccessToken: Boolean(result.source?.hasAccessToken),
hasRefreshToken: Boolean(result.source?.hasRefreshToken),
accessTokenStatus: String(result.source?.accessTokenStatus || '').trim(),
},
}
},
},
),
)
router.post(
'/kuaishou-industry-source/refresh-token',
createJsonHandler(() => refreshAdminKuaishouIndustryAccessToken(), {
successMessage: '快手行业电子凭证 accessToken 已刷新',
errorMessage: '刷新快手行业电子凭证 accessToken 失败',
scope: '[admin/platform-config/kuaishou-industry-source/refresh-token]',
audit: (_req, data) => {
const result = data as JsonRecord
return {
action: 'platform_kuaishou_industry_access_token_refreshed',
targetType: 'platform_config',
targetId: 'kuaishou_industry_source',
data: {
refreshed: Boolean(result.refreshed),
accessTokenExpiresAt: String(result.source?.accessTokenExpiresAt || '').trim(),
accessTokenStatus: String(result.source?.accessTokenStatus || '').trim(),
},
}
},
}),
)
export default router
@@ -0,0 +1,172 @@
import { maskSecret } from '../../../utils/masking.js'
import {
getKuaishouIndustrySourceConfig,
getKuaishouIndustrySourceFilePath,
saveKuaishouIndustrySourceConfig,
type KuaishouIndustrySourceConfig,
} from '../../platforms/kuaishou-industry/source-config-service.js'
import { refreshKuaishouIndustryAccessToken } from '../../platforms/kuaishou-industry/token-service.js'
type JsonObject = Record<string, any>
const SECRET_FIELDS = [
'appSecret',
'signSecret',
'messageSecret',
'accessToken',
'refreshToken',
] as const
export function getAdminKuaishouIndustrySourceConfig() {
const config = getKuaishouIndustrySourceConfig()
return {
filePath: getKuaishouIndustrySourceFilePath(),
source: mapAdminKuaishouIndustrySourceConfig(config),
}
}
export function updateAdminKuaishouIndustrySourceConfig(payload: JsonObject = {}) {
const current = getKuaishouIndustrySourceConfig()
const saved = saveKuaishouIndustrySourceConfig({
...current,
enabled: hasPayloadField(payload, 'enabled') ? payload.enabled !== false : current.enabled !== false,
sendCallbackEnabled: hasPayloadField(payload, 'sendCallbackEnabled')
? payload.sendCallbackEnabled === true
: current.sendCallbackEnabled === true,
baseUrl: readConfigString(payload, 'baseUrl', current.baseUrl),
authBaseUrl: readConfigString(payload, 'authBaseUrl', current.authBaseUrl),
appKey: readConfigString(payload, 'appKey', current.appKey, { allowBlank: true }),
sellerId: readConfigString(payload, 'sellerId', current.sellerId, { allowBlank: true }),
provider: readConfigString(payload, 'provider', current.provider),
platform: readConfigString(payload, 'platform', current.platform),
shopId: readConfigString(payload, 'shopId', current.shopId),
shopName: readConfigString(payload, 'shopName', current.shopName),
version: readConfigString(payload, 'version', current.version),
accessTokenExpiresAt: readConfigString(payload, 'accessTokenExpiresAt', current.accessTokenExpiresAt, { allowBlank: true }),
refreshTokenExpiresAt: readConfigString(payload, 'refreshTokenExpiresAt', current.refreshTokenExpiresAt, { allowBlank: true }),
...resolveSecretPatch(payload, current),
})
return {
filePath: getKuaishouIndustrySourceFilePath(),
source: mapAdminKuaishouIndustrySourceConfig(saved),
}
}
export async function refreshAdminKuaishouIndustryAccessToken() {
const result = await refreshKuaishouIndustryAccessToken()
return {
filePath: getKuaishouIndustrySourceFilePath(),
refreshed: result.refreshed,
source: mapAdminKuaishouIndustrySourceConfig(result.config),
}
}
function mapAdminKuaishouIndustrySourceConfig(config: KuaishouIndustrySourceConfig) {
const accessTokenStatus = resolveAccessTokenStatus(config)
return {
enabled: config.enabled !== false,
sendCallbackEnabled: config.sendCallbackEnabled === true,
baseUrl: config.baseUrl,
authBaseUrl: config.authBaseUrl,
appKey: config.appKey,
appSecret: '',
appSecretMasked: maskSecret(config.appSecret),
hasAppSecret: Boolean(config.appSecret),
signSecret: '',
signSecretMasked: maskSecret(config.signSecret),
hasSignSecret: Boolean(config.signSecret),
messageSecret: '',
messageSecretMasked: maskSecret(config.messageSecret),
hasMessageSecret: Boolean(config.messageSecret),
accessToken: '',
accessTokenMasked: maskSecret(config.accessToken),
hasAccessToken: Boolean(config.accessToken),
refreshToken: '',
refreshTokenMasked: maskSecret(config.refreshToken),
hasRefreshToken: Boolean(config.refreshToken),
accessTokenExpiresAt: config.accessTokenExpiresAt,
refreshTokenExpiresAt: config.refreshTokenExpiresAt,
accessTokenStatus: accessTokenStatus.status,
accessTokenExpiresInSeconds: accessTokenStatus.expiresInSeconds,
sellerId: config.sellerId,
provider: config.provider,
platform: config.platform,
shopId: config.shopId,
shopName: config.shopName,
version: config.version,
lastRefreshedAt: config.lastRefreshedAt,
lastRefreshError: config.lastRefreshError,
}
}
function resolveSecretPatch(
payload: JsonObject,
current: KuaishouIndustrySourceConfig,
): Partial<KuaishouIndustrySourceConfig> {
const patch: Partial<KuaishouIndustrySourceConfig> = {}
for (const field of SECRET_FIELDS) {
const text = String(payload[field] || '').trim()
patch[field] = text || current[field]
}
return patch
}
function resolveAccessTokenStatus(config: KuaishouIndustrySourceConfig) {
if (!config.accessToken) {
return {
status: 'missing',
expiresInSeconds: null,
}
}
const expiresAt = Date.parse(config.accessTokenExpiresAt || '')
if (!Number.isFinite(expiresAt)) {
return {
status: 'unknown',
expiresInSeconds: null,
}
}
const expiresInSeconds = Math.floor((expiresAt - Date.now()) / 1000)
if (expiresInSeconds <= 0) {
return {
status: 'expired',
expiresInSeconds,
}
}
if (expiresInSeconds <= 30 * 60) {
return {
status: 'expiring',
expiresInSeconds,
}
}
return {
status: 'valid',
expiresInSeconds,
}
}
function readConfigString(
payload: JsonObject,
field: string,
fallback: string,
options: { allowBlank?: boolean } = {},
): string {
if (!hasPayloadField(payload, field)) {
return fallback
}
const text = String(payload[field] || '').trim()
return text || (options.allowBlank ? '' : fallback)
}
function hasPayloadField(payload: JsonObject, field: string): boolean {
return Object.prototype.hasOwnProperty.call(payload, field)
}
@@ -1,24 +1,33 @@
import { runtimeConfig } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js' import { createHttpError } from '../../../utils/http.js'
import type { RuntimeConfig } from '../../../types/runtime-config.js' import {
getKuaishouIndustrySourceConfig,
type KuaishouIndustrySourceConfig,
} from './source-config-service.js'
export const KUISHOU_INDUSTRY_PROVIDER = 'kuaishou-industry' export const KUISHOU_INDUSTRY_PROVIDER = 'kuaishou-industry'
export const KUISHOU_INDUSTRY_PLATFORM = 'kuaishou' export const KUISHOU_INDUSTRY_PLATFORM = 'kuaishou'
type KuaishouIndustryRuntimeConfig = RuntimeConfig['platforms']['kuaishouIndustry'] export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustrySourceConfig> = {}) {
export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustryRuntimeConfig> = {}) {
const config = { const config = {
...(runtimeConfig.platforms?.kuaishouIndustry || {}), ...getKuaishouIndustrySourceConfig(),
...overrides, ...overrides,
} }
return { return {
enabled: config.enabled !== false,
baseUrl: String(config.baseUrl || '').trim(),
authBaseUrl: String(config.authBaseUrl || '').trim(),
appKey: String(config.appKey || '').trim(), appKey: String(config.appKey || '').trim(),
appSecret: String(config.appSecret || '').trim(), appSecret: String(config.appSecret || '').trim(),
signSecret: String(config.signSecret || '').trim(), signSecret: String(config.signSecret || '').trim(),
messageSecret: String(config.messageSecret || '').trim(), messageSecret: String(config.messageSecret || '').trim(),
accessToken: String(config.accessToken || '').trim(), accessToken: String(config.accessToken || '').trim(),
refreshToken: String(config.refreshToken || '').trim(),
accessTokenExpiresAt: String(config.accessTokenExpiresAt || '').trim(),
refreshTokenExpiresAt: String(config.refreshTokenExpiresAt || '').trim(),
sellerId: String(config.sellerId || '').trim(),
lastRefreshedAt: String(config.lastRefreshedAt || '').trim(),
lastRefreshError: String(config.lastRefreshError || '').trim(),
sendCallbackEnabled: Boolean(config.sendCallbackEnabled), sendCallbackEnabled: Boolean(config.sendCallbackEnabled),
provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER, provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM, platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM,
@@ -2,10 +2,11 @@ import crypto from 'node:crypto'
import { logInfo, logWarn } from '../../../utils/logger.js' import { logInfo, logWarn } from '../../../utils/logger.js'
import { getKuaishouIndustryConfig } from './config.js' import { getKuaishouIndustryConfig } from './config.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
type ConsumeCallbackInput = { type ConsumeCallbackInput = {
oid: string oid: string
@@ -32,13 +33,28 @@ type ConsumeCallbackInput = {
} }
export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> { export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
const config = getKuaishouIndustryConfig() let config = getKuaishouIndustryConfig()
if (!config.sendCallbackEnabled) { if (!config.enabled || !config.sendCallbackEnabled) {
logInfo('[kuaishou-industry/consume-callback]', '回调未启用,跳过') logInfo('[kuaishou-industry/consume-callback]', '回调未启用,跳过')
return { success: true } return { success: true }
} }
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({ config })
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 刷新失败,无法发起核销回调', {
error: message,
})
return { success: false, error: message }
}
if (!config.accessToken) { if (!config.accessToken) {
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 未配置,跳过') logWarn('[kuaishou-industry/consume-callback]', 'accessToken 未配置,跳过')
return { success: true } return { success: true }
@@ -89,7 +105,7 @@ export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ su
body.append(key, String(value)) body.append(key, String(value))
} }
const url = `${KUAISHOU_OPEN_API}/integration/callback/virtual/eticket/consume` const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/consume`
logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, { logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, {
url, url,
@@ -182,3 +198,7 @@ function resolveCallbackErrorDetail(error: unknown): JsonObject {
return detail return detail
} }
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
}
@@ -2,10 +2,11 @@ import crypto from 'node:crypto'
import { logInfo, logWarn } from '../../../utils/logger.js' import { logInfo, logWarn } from '../../../utils/logger.js'
import { getKuaishouIndustryConfig } from './config.js' import { getKuaishouIndustryConfig } from './config.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
type DestroyCallbackInput = { type DestroyCallbackInput = {
oid: string oid: string
@@ -22,13 +23,28 @@ type DestroyCallbackInput = {
} }
export async function destroyCallback(input: DestroyCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> { export async function destroyCallback(input: DestroyCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
const config = getKuaishouIndustryConfig() let config = getKuaishouIndustryConfig()
if (!config.sendCallbackEnabled) { if (!config.enabled || !config.sendCallbackEnabled) {
logInfo('[kuaishou-industry/destroy-callback]', '回调未启用,跳过') logInfo('[kuaishou-industry/destroy-callback]', '回调未启用,跳过')
return { success: true } return { success: true }
} }
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({ config })
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 刷新失败,无法发起销毁回调', {
error: message,
})
return { success: false, error: message }
}
if (!config.accessToken) { if (!config.accessToken) {
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 未配置,跳过') logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 未配置,跳过')
return { success: true } return { success: true }
@@ -72,7 +88,7 @@ export async function destroyCallback(input: DestroyCallbackInput): Promise<{ su
body.append(key, String(value)) body.append(key, String(value))
} }
const url = `${KUAISHOU_OPEN_API}/integration/callback/virtual/eticket/destroy` const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/destroy`
logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, { logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, {
url, url,
@@ -164,3 +180,7 @@ function resolveCallbackErrorDetail(error: unknown): JsonObject {
return detail return detail
} }
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
}
@@ -2,10 +2,11 @@ import crypto from 'node:crypto'
import { logInfo, logWarn } from '../../../utils/logger.js' import { logInfo, logWarn } from '../../../utils/logger.js'
import { getKuaishouIndustryConfig } from './config.js' import { getKuaishouIndustryConfig } from './config.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
type SendCallbackInput = { type SendCallbackInput = {
oid: string oid: string
@@ -28,13 +29,28 @@ type SendCallbackInput = {
} }
export async function sendCallback(input: SendCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> { export async function sendCallback(input: SendCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
const config = getKuaishouIndustryConfig() let config = getKuaishouIndustryConfig()
if (!config.sendCallbackEnabled) { if (!config.enabled || !config.sendCallbackEnabled) {
logInfo('[kuaishou-industry/send-callback]', '发码回调未启用,跳过') logInfo('[kuaishou-industry/send-callback]', '发码回调未启用,跳过')
return { success: true } return { success: true }
} }
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({ config })
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logWarn('[kuaishou-industry/send-callback]', 'accessToken 刷新失败,无法发起发码回调', {
error: message,
})
return { success: false, error: message }
}
if (!config.accessToken) { if (!config.accessToken) {
logWarn('[kuaishou-industry/send-callback]', 'accessToken 未配置,跳过发码回调') logWarn('[kuaishou-industry/send-callback]', 'accessToken 未配置,跳过发码回调')
return { success: true } return { success: true }
@@ -84,7 +100,7 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
body.append(key, String(value)) body.append(key, String(value))
} }
const url = `${KUAISHOU_OPEN_API}/integration/callback/virtual/eticket/send` const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/send`
logInfo('[kuaishou-industry/send-callback]', `发起发码回调 oid=${input.oid}`, { logInfo('[kuaishou-industry/send-callback]', `发起发码回调 oid=${input.oid}`, {
url, url,
@@ -176,3 +192,7 @@ function resolveCallbackErrorDetail(error: unknown): JsonObject {
return detail return detail
} }
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
}
@@ -0,0 +1,142 @@
import path from 'node:path'
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
import { readJsonFile, writeJsonFile } from '../../../utils/json-file-store.js'
const KUAISHOU_INDUSTRY_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-industry-source.json')
const DEFAULT_CALLBACK_BASE_URL = 'https://openapi.kwaixiaodian.com'
const DEFAULT_AUTH_BASE_URL = 'https://open.kwaixiaodian.com'
type JsonObject = Record<string, any>
export type KuaishouIndustrySourceConfig = {
enabled: boolean
sendCallbackEnabled: boolean
baseUrl: string
authBaseUrl: string
appKey: string
appSecret: string
signSecret: string
messageSecret: string
accessToken: string
refreshToken: string
accessTokenExpiresAt: string
refreshTokenExpiresAt: string
sellerId: string
provider: string
platform: string
shopId: string
shopName: string
version: string
lastRefreshedAt: string
lastRefreshError: string
}
export function getKuaishouIndustrySourceFilePath(): string {
return KUAISHOU_INDUSTRY_SOURCE_FILE_PATH
}
export function getKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConfig {
return readJsonFile(
KUAISHOU_INDUSTRY_SOURCE_FILE_PATH,
createDefaultKuaishouIndustrySourceConfig,
normalizeKuaishouIndustrySourceConfig,
)
}
export function saveKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig {
return writeJsonFile(
KUAISHOU_INDUSTRY_SOURCE_FILE_PATH,
rawValue,
normalizeKuaishouIndustrySourceConfig,
)
}
export function patchKuaishouIndustrySourceConfig(
patch: Partial<KuaishouIndustrySourceConfig>,
): KuaishouIndustrySourceConfig {
return saveKuaishouIndustrySourceConfig({
...getKuaishouIndustrySourceConfig(),
...patch,
})
}
function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig {
const fallback = createDefaultKuaishouIndustrySourceConfig()
const source = isPlainObject(rawValue) ? rawValue : {}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : fallback.enabled,
sendCallbackEnabled: typeof source.sendCallbackEnabled === 'boolean'
? source.sendCallbackEnabled
: fallback.sendCallbackEnabled,
baseUrl: normalizeUrlLike(source.baseUrl, fallback.baseUrl),
authBaseUrl: normalizeUrlLike(source.authBaseUrl, fallback.authBaseUrl),
appKey: normalizeString(source.appKey, fallback.appKey),
appSecret: normalizeString(source.appSecret, fallback.appSecret),
signSecret: normalizeString(source.signSecret, fallback.signSecret),
messageSecret: normalizeString(source.messageSecret, fallback.messageSecret),
accessToken: normalizeString(source.accessToken, fallback.accessToken),
refreshToken: normalizeString(source.refreshToken, fallback.refreshToken),
accessTokenExpiresAt: normalizeNullableIso(source.accessTokenExpiresAt),
refreshTokenExpiresAt: normalizeNullableIso(source.refreshTokenExpiresAt),
sellerId: normalizeString(source.sellerId, fallback.sellerId),
provider: normalizeString(source.provider, fallback.provider),
platform: normalizeString(source.platform, fallback.platform),
shopId: normalizeString(source.shopId, fallback.shopId),
shopName: normalizeString(source.shopName, fallback.shopName),
version: normalizeString(source.version, fallback.version),
lastRefreshedAt: normalizeNullableIso(source.lastRefreshedAt),
lastRefreshError: normalizeString(source.lastRefreshError, ''),
}
}
function createDefaultKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConfig {
const runtime = runtimeConfig.platforms?.kuaishouIndustry || {}
return {
enabled: true,
sendCallbackEnabled: Boolean(runtime.sendCallbackEnabled),
baseUrl: DEFAULT_CALLBACK_BASE_URL,
authBaseUrl: DEFAULT_AUTH_BASE_URL,
appKey: String(runtime.appKey || '').trim(),
appSecret: String(runtime.appSecret || '').trim(),
signSecret: String(runtime.signSecret || '').trim(),
messageSecret: String(runtime.messageSecret || '').trim(),
accessToken: '',
refreshToken: '',
accessTokenExpiresAt: '',
refreshTokenExpiresAt: '',
sellerId: '',
provider: String(runtime.provider || 'kuaishou-industry').trim() || 'kuaishou-industry',
platform: String(runtime.platform || 'kuaishou').trim() || 'kuaishou',
shopId: String(runtime.shopId || 'kuaishou-industry').trim() || 'kuaishou-industry',
shopName: String(runtime.shopName || '快手行业电子凭证').trim() || '快手行业电子凭证',
version: String(runtime.version || '1').trim() || '1',
lastRefreshedAt: '',
lastRefreshError: '',
}
}
function normalizeString(value: unknown, fallback: string): string {
const text = String(value || '').trim()
return text || fallback
}
function normalizeUrlLike(value: unknown, fallback: string): string {
return String(value || fallback).trim().replace(/\/+$/, '') || fallback
}
function normalizeNullableIso(value: unknown): string {
const text = String(value || '').trim()
if (!text) {
return ''
}
const timestamp = Date.parse(text)
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : ''
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,66 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
shouldRefreshAccessToken,
} from './token-service.js'
import type {
KuaishouIndustrySourceConfig,
} from './source-config-service.js'
test('shouldRefreshAccessToken refreshes missing or expiring tokens', () => {
assert.equal(shouldRefreshAccessToken(createConfig({
accessToken: '',
accessTokenExpiresAt: '',
})), true)
assert.equal(shouldRefreshAccessToken(createConfig({
accessToken: 'token',
accessTokenExpiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
})), true)
assert.equal(shouldRefreshAccessToken(createConfig({
accessToken: 'token',
accessTokenExpiresAt: new Date(Date.now() - 10 * 1000).toISOString(),
})), true)
})
test('shouldRefreshAccessToken keeps valid tokens without a forced refresh', () => {
assert.equal(shouldRefreshAccessToken(createConfig({
accessToken: 'token',
accessTokenExpiresAt: '',
})), false)
assert.equal(shouldRefreshAccessToken(createConfig({
accessToken: 'token',
accessTokenExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
})), false)
})
function createConfig(
patch: Partial<KuaishouIndustrySourceConfig> = {},
): KuaishouIndustrySourceConfig {
return {
enabled: true,
sendCallbackEnabled: true,
baseUrl: 'https://openapi.kwaixiaodian.com',
authBaseUrl: 'https://open.kwaixiaodian.com',
appKey: 'app-key',
appSecret: 'app-secret',
signSecret: 'sign-secret',
messageSecret: 'message-secret',
accessToken: 'token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
refreshTokenExpiresAt: '',
sellerId: '',
provider: 'kuaishou-industry',
platform: 'kuaishou',
shopId: 'kuaishou-industry',
shopName: '快手行业电子凭证',
version: '1',
lastRefreshedAt: '',
lastRefreshError: '',
...patch,
}
}
@@ -0,0 +1,282 @@
import { createHttpError } from '../../../utils/http.js'
import { logInfo, logWarn } from '../../../utils/logger.js'
import {
getKuaishouIndustrySourceConfig,
patchKuaishouIndustrySourceConfig,
type KuaishouIndustrySourceConfig,
} from './source-config-service.js'
type JsonObject = Record<string, any>
const ACCESS_TOKEN_REFRESH_MARGIN_MS = 30 * 60 * 1000
const DEFAULT_ACCESS_TOKEN_TTL_MS = 47 * 60 * 60 * 1000
export type KuaishouIndustryAccessTokenResult = {
accessToken: string
refreshed: boolean
config: KuaishouIndustrySourceConfig
}
export async function ensureKuaishouIndustryAccessToken(
options: {
forceRefresh?: boolean
config?: KuaishouIndustrySourceConfig
} = {},
): Promise<KuaishouIndustryAccessTokenResult> {
const config = options.config || getKuaishouIndustrySourceConfig()
const accessToken = String(config.accessToken || '').trim()
const refreshToken = String(config.refreshToken || '').trim()
if (!options.forceRefresh && accessToken && !shouldRefreshAccessToken(config)) {
return { accessToken, refreshed: false, config }
}
if (!refreshToken) {
return { accessToken, refreshed: false, config }
}
return refreshKuaishouIndustryAccessToken(config)
}
export async function refreshKuaishouIndustryAccessToken(
config: KuaishouIndustrySourceConfig = getKuaishouIndustrySourceConfig(),
): Promise<KuaishouIndustryAccessTokenResult> {
assertRefreshConfig(config)
const authBaseUrl = String(config.authBaseUrl || '').trim().replace(/\/+$/, '')
const url = new URL(`${authBaseUrl}/oauth2/refresh_token`)
url.searchParams.set('app_id', config.appKey)
url.searchParams.set('app_secret', config.appSecret)
url.searchParams.set('grant_type', 'refresh_token')
url.searchParams.set('refresh_token', config.refreshToken)
try {
const startedAt = Date.now()
const response = await fetch(url)
const text = await response.text()
const json = parseJsonObject(text)
const tokenPayload = normalizeTokenRefreshResponse(json, config)
if (!response.ok || !tokenPayload.accessToken) {
const message = resolveTokenRefreshErrorMessage(json, response.status)
patchKuaishouIndustrySourceConfig({
lastRefreshError: message,
})
throw createHttpError(message, {
statusCode: 502,
errorCode: 'kuaishou_industry_access_token_refresh_failed',
})
}
const saved = patchKuaishouIndustrySourceConfig({
accessToken: tokenPayload.accessToken,
refreshToken: tokenPayload.refreshToken || config.refreshToken,
accessTokenExpiresAt: tokenPayload.accessTokenExpiresAt,
refreshTokenExpiresAt: tokenPayload.refreshTokenExpiresAt || config.refreshTokenExpiresAt,
sellerId: tokenPayload.sellerId || config.sellerId,
lastRefreshedAt: new Date().toISOString(),
lastRefreshError: '',
})
logInfo('[kuaishou-industry/token]', 'accessToken 刷新成功', {
durationMs: Date.now() - startedAt,
accessTokenExpiresAt: saved.accessTokenExpiresAt || '',
refreshTokenExpiresAt: saved.refreshTokenExpiresAt || '',
hasRefreshToken: Boolean(saved.refreshToken),
})
return {
accessToken: saved.accessToken,
refreshed: true,
config: saved,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
patchKuaishouIndustrySourceConfig({
lastRefreshError: message,
})
logWarn('[kuaishou-industry/token]', 'accessToken 刷新失败', resolveRefreshErrorDetail(error))
throw error
}
}
export function shouldRefreshAccessToken(config: KuaishouIndustrySourceConfig): boolean {
if (!String(config.accessToken || '').trim()) {
return true
}
const expiresAt = Date.parse(String(config.accessTokenExpiresAt || '').trim())
if (!Number.isFinite(expiresAt)) {
return false
}
return expiresAt <= Date.now() + ACCESS_TOKEN_REFRESH_MARGIN_MS
}
function assertRefreshConfig(config: KuaishouIndustrySourceConfig) {
if (!config.authBaseUrl) {
throw createHttpError('快手开放平台授权地址未配置', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_auth_base_url',
})
}
if (!config.appKey || !config.appSecret) {
throw createHttpError('快手 appKey/appSecret 未配置,无法刷新 accessToken', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_oauth_credential',
})
}
if (!config.refreshToken) {
throw createHttpError('refreshToken 未配置,无法刷新 accessToken', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_refresh_token',
})
}
}
function normalizeTokenRefreshResponse(
json: JsonObject,
currentConfig: KuaishouIndustrySourceConfig,
) {
const payload = isPlainObject(json.data) ? json.data : json
const accessToken = pickFirstString(payload, [
'access_token',
'accessToken',
'accessTokenValue',
])
const refreshToken = pickFirstString(payload, [
'refresh_token',
'refreshToken',
'refreshTokenValue',
])
const sellerId = pickFirstString(payload, ['seller_id', 'sellerId'])
return {
accessToken,
refreshToken,
sellerId,
accessTokenExpiresAt: resolveExpiresAt(payload, [
'access_token_expires_at',
'accessTokenExpiresAt',
'accessTokenExpireAt',
'expires_at',
'expireAt',
], [
'expires_in',
'expiresIn',
'access_token_expires_in',
'accessTokenExpiresIn',
], DEFAULT_ACCESS_TOKEN_TTL_MS),
refreshTokenExpiresAt: resolveExpiresAt(payload, [
'refresh_token_expires_at',
'refreshTokenExpiresAt',
'refreshTokenExpireAt',
], [
'refresh_token_expires_in',
'refreshTokenExpiresIn',
], 0) || currentConfig.refreshTokenExpiresAt,
}
}
function resolveExpiresAt(
payload: JsonObject,
dateKeys: string[],
durationKeys: string[],
fallbackDurationMs: number,
): string {
for (const key of dateKeys) {
const value = payload[key]
const timestamp = parseTimestampLike(value)
if (Number.isFinite(timestamp) && timestamp > 0) {
return new Date(timestamp).toISOString()
}
}
for (const key of durationKeys) {
const seconds = Number(payload[key])
if (Number.isFinite(seconds) && seconds > 0) {
return new Date(Date.now() + seconds * 1000).toISOString()
}
}
return fallbackDurationMs > 0
? new Date(Date.now() + fallbackDurationMs).toISOString()
: ''
}
function parseTimestampLike(value: unknown): number {
const raw = typeof value === 'number'
? value
: Number(String(value || '').trim())
if (Number.isFinite(raw) && raw > 0) {
return raw > 10_000_000_000 ? raw : raw * 1000
}
return Date.parse(String(value || '').trim())
}
function resolveTokenRefreshErrorMessage(json: JsonObject, status: number): string {
const payload = isPlainObject(json.data) ? json.data : json
return pickFirstString(payload, [
'error_msg',
'errorMsg',
'message',
'msg',
'error_description',
'errorDescription',
]) || `快手 accessToken 刷新失败,HTTP ${status}`
}
function parseJsonObject(text: string): JsonObject {
try {
const parsed = JSON.parse(text || '{}')
return isPlainObject(parsed) ? parsed : { raw: text }
} catch {
return { raw: text }
}
}
function pickFirstString(payload: JsonObject, keys: string[]): string {
for (const key of keys) {
const text = String(payload[key] || '').trim()
if (text) {
return text
}
}
return ''
}
function resolveRefreshErrorDetail(error: unknown): JsonObject {
const detail: JsonObject = {
error: error instanceof Error ? error.message : String(error),
}
const cause = error && typeof error === 'object' && 'cause' in error
? (error as { cause?: unknown }).cause
: null
if (!cause || typeof cause !== 'object') {
return detail
}
const current = cause as Record<string, unknown>
for (const [key, value] of [
['causeMessage', current.message],
['causeCode', current.code],
['causeSyscall', current.syscall],
['causeHostname', current.hostname],
] as Array<[string, unknown]>) {
const text = String(value || '').trim()
if (text) {
detail[key] = text
}
}
return detail
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -12,6 +12,7 @@ import type {
AdminCloudtentaclesSkuBuyInput, AdminCloudtentaclesSkuBuyInput,
AdminCloudtentaclesSkuUseInput, AdminCloudtentaclesSkuUseInput,
AdminCloudtentaclesSourceConfigInput, AdminCloudtentaclesSourceConfigInput,
AdminKuaishouIndustrySourceConfigInput,
AdminCloudtentaclesTestLoginInput, AdminCloudtentaclesTestLoginInput,
AdminCloudtentaclesValidateSessionInput, AdminCloudtentaclesValidateSessionInput,
AdminCloudtentaclesVirtualNumberInput, AdminCloudtentaclesVirtualNumberInput,
@@ -31,6 +32,7 @@ export type AdminTaskRouteQuery = AdminTaskListQueryInput
export type AdminRouteAdminSession = AdminViewerSessionInput export type AdminRouteAdminSession = AdminViewerSessionInput
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
export type AdminKuaishouIndustrySourceConfigRouteBody = AdminKuaishouIndustrySourceConfigInput
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
export type AdminNotificationTestRouteBody = AdminNotificationTestInput export type AdminNotificationTestRouteBody = AdminNotificationTestInput
export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput
@@ -12,6 +12,27 @@ export type AdminKuaishouEticketSourceConfigInput = {
shops?: AdminKuaishouEticketShopConfigWriteItemInput[] shops?: AdminKuaishouEticketShopConfigWriteItemInput[]
} }
export type AdminKuaishouIndustrySourceConfigInput = {
enabled?: boolean
sendCallbackEnabled?: boolean
baseUrl?: string
authBaseUrl?: string
appKey?: string
appSecret?: string
signSecret?: string
messageSecret?: string
accessToken?: string
refreshToken?: string
accessTokenExpiresAt?: string
refreshTokenExpiresAt?: string
sellerId?: string
provider?: string
platform?: string
shopId?: string
shopName?: string
version?: string
}
export type AdminNotificationBarkRecipientInput = { export type AdminNotificationBarkRecipientInput = {
id?: string id?: string
name?: string name?: string
-1
View File
@@ -92,7 +92,6 @@ export type RuntimeConfig = {
appSecret: string; appSecret: string;
signSecret: string; signSecret: string;
messageSecret: string; messageSecret: string;
accessToken: string;
sendCallbackEnabled: boolean; sendCallbackEnabled: boolean;
provider: string; provider: string;
platform: string; platform: string;
@@ -38,17 +38,20 @@ import {
fetchAdminCloudtentaclesSourceConfig, fetchAdminCloudtentaclesSourceConfig,
fetchAdminKuaishouEticketSourceConfig, fetchAdminKuaishouEticketSourceConfig,
fetchAdminKuaishouFeifeiConfig, fetchAdminKuaishouFeifeiConfig,
fetchAdminKuaishouIndustrySourceConfig,
fetchAdminNinetyoneOrders, fetchAdminNinetyoneOrders,
fetchAdminNotificationConfig, fetchAdminNotificationConfig,
fetchAdminScheduledJobsConfig, fetchAdminScheduledJobsConfig,
matchAdminKuaishouFeifeiProduct, matchAdminKuaishouFeifeiProduct,
queryAdminKuaishouEticketDetail, queryAdminKuaishouEticketDetail,
queryAdminKuaishouEticketShopInfo, queryAdminKuaishouEticketShopInfo,
refreshAdminKuaishouIndustryAccessToken,
retryAdminNinetyoneOrder, retryAdminNinetyoneOrder,
runAdminScheduledJob, runAdminScheduledJob,
saveAdminCloudtentaclesSourceConfig, saveAdminCloudtentaclesSourceConfig,
saveAdminKuaishouEticketSourceConfig, saveAdminKuaishouEticketSourceConfig,
saveAdminKuaishouFeifeiConfig, saveAdminKuaishouFeifeiConfig,
saveAdminKuaishouIndustrySourceConfig,
saveAdminNotificationConfig, saveAdminNotificationConfig,
saveAdminScheduledJobsConfig, saveAdminScheduledJobsConfig,
sendAdminCloudtentaclesSmsCode, sendAdminCloudtentaclesSmsCode,
@@ -64,6 +67,8 @@ import type {
AdminKuaishouEticketShopConfigItem, AdminKuaishouEticketShopConfigItem,
AdminKuaishouEticketShopInfoResult, AdminKuaishouEticketShopInfoResult,
AdminKuaishouEticketSourceConfig, AdminKuaishouEticketSourceConfig,
AdminKuaishouIndustryConfigResponse,
AdminKuaishouIndustrySourceConfig,
AdminKuaishouFeifeiConfig, AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiConfigResponse, AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiMatchResult, AdminKuaishouFeifeiMatchResult,
@@ -83,6 +88,7 @@ type PlatformTab =
| 'ninetyone' | 'ninetyone'
| 'notifications' | 'notifications'
| 'kuaishouEticket' | 'kuaishouEticket'
| 'kuaishouIndustry'
| 'kuaishouFeifei' | 'kuaishouFeifei'
| 'cloudtentacles' | 'cloudtentacles'
@@ -110,6 +116,7 @@ const platformTabs: PlatformTab[] = [
'ninetyone', 'ninetyone',
'notifications', 'notifications',
'kuaishouEticket', 'kuaishouEticket',
'kuaishouIndustry',
'kuaishouFeifei', 'kuaishouFeifei',
'cloudtentacles', 'cloudtentacles',
] ]
@@ -125,6 +132,7 @@ export default function AdminPlatformShopsPage() {
filePath: string filePath: string
source: AdminKuaishouEticketSourceConfig source: AdminKuaishouEticketSourceConfig
} | null>(null) } | null>(null)
const [industryConfig, setIndustryConfig] = useState<AdminKuaishouIndustryConfigResponse | null>(null)
const [feifeiConfig, setFeifeiConfig] = useState<AdminKuaishouFeifeiConfigResponse | null>(null) const [feifeiConfig, setFeifeiConfig] = useState<AdminKuaishouFeifeiConfigResponse | null>(null)
const [cloudtentaclesConfig, setCloudtentaclesConfig] = const [cloudtentaclesConfig, setCloudtentaclesConfig] =
useState<AdminCloudtentaclesSourceConfigResponse | null>(null) useState<AdminCloudtentaclesSourceConfigResponse | null>(null)
@@ -148,6 +156,7 @@ export default function AdminPlatformShopsPage() {
notificationResponse, notificationResponse,
scheduledJobsResponse, scheduledJobsResponse,
eticketResponse, eticketResponse,
industryResponse,
feifeiResponse, feifeiResponse,
cloudtentaclesResponse, cloudtentaclesResponse,
ninetyoneResponse, ninetyoneResponse,
@@ -155,6 +164,7 @@ export default function AdminPlatformShopsPage() {
fetchAdminNotificationConfig(), fetchAdminNotificationConfig(),
fetchAdminScheduledJobsConfig(), fetchAdminScheduledJobsConfig(),
fetchAdminKuaishouEticketSourceConfig(), fetchAdminKuaishouEticketSourceConfig(),
fetchAdminKuaishouIndustrySourceConfig(),
fetchAdminKuaishouFeifeiConfig(), fetchAdminKuaishouFeifeiConfig(),
fetchAdminCloudtentaclesSourceConfig(), fetchAdminCloudtentaclesSourceConfig(),
fetchAdminNinetyoneOrders({ page: 1, pageSize: 20 }), fetchAdminNinetyoneOrders({ page: 1, pageSize: 20 }),
@@ -163,6 +173,7 @@ export default function AdminPlatformShopsPage() {
setNotificationConfig(notificationResponse.data) setNotificationConfig(notificationResponse.data)
setScheduledJobs(scheduledJobsResponse.data) setScheduledJobs(scheduledJobsResponse.data)
setEticketConfig(eticketResponse.data) setEticketConfig(eticketResponse.data)
setIndustryConfig(industryResponse.data)
setFeifeiConfig(feifeiResponse.data) setFeifeiConfig(feifeiResponse.data)
setCloudtentaclesConfig(cloudtentaclesResponse.data) setCloudtentaclesConfig(cloudtentaclesResponse.data)
setNinetyoneData(ninetyoneResponse.data) setNinetyoneData(ninetyoneResponse.data)
@@ -243,6 +254,18 @@ export default function AdminPlatformShopsPage() {
<Empty description="快手小店配置未加载" /> <Empty description="快手小店配置未加载" />
), ),
}, },
{
key: 'kuaishouIndustry',
label: '行业电子凭证',
children: industryConfig ? (
<KuaishouIndustryPanel
config={industryConfig}
onChange={setIndustryConfig}
/>
) : (
<Empty description="快手行业电子凭证配置未加载" />
),
},
{ {
key: 'kuaishouFeifei', key: 'kuaishouFeifei',
label: 'kuaishou-feifei', label: 'kuaishou-feifei',
@@ -1037,6 +1060,201 @@ function KuaishouEticketPanel({
) )
} }
function KuaishouIndustryPanel({
config,
onChange,
}: {
config: AdminKuaishouIndustryConfigResponse
onChange: (config: AdminKuaishouIndustryConfigResponse) => void
}) {
const [saving, setSaving] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const source = config.source
async function saveConfig() {
setSaving(true)
try {
const response = await saveAdminKuaishouIndustrySourceConfig(source)
onChange(response.data)
showSuccess('快手行业电子凭证配置已保存')
} catch (error) {
showError(error instanceof Error ? error.message : '保存快手行业电子凭证配置失败')
} finally {
setSaving(false)
}
}
async function refreshToken() {
setRefreshing(true)
try {
const saved = await saveAdminKuaishouIndustrySourceConfig(source)
onChange(saved.data)
const response = await refreshAdminKuaishouIndustryAccessToken()
onChange(response.data)
showSuccess('accessToken 已刷新')
} catch (error) {
showError(error instanceof Error ? error.message : '刷新 accessToken 失败')
} finally {
setRefreshing(false)
}
}
function updateSource(patch: Partial<AdminKuaishouIndustrySourceConfig>) {
onChange({
...config,
source: {
...source,
...patch,
},
})
}
function renderSecretInput(
label: string,
field: 'appSecret' | 'signSecret' | 'messageSecret' | 'accessToken' | 'refreshToken',
masked: string,
) {
return (
<div>
<Typography.Text type="secondary">{label}</Typography.Text>
<Input.Password
value={source[field]}
placeholder={masked || '未配置'}
onChange={(event) => updateSource({ [field]: event.target.value })}
/>
</div>
)
}
const accessTokenStatus = resolveIndustryAccessTokenStatus(source)
return (
<section className="platform-panel-stack">
{source.lastRefreshError ? (
<Alert
type="warning"
showIcon
message="最近一次刷新失败"
description={source.lastRefreshError}
/>
) : null}
<div className="metric-grid four">
<MetricCard
label="回调状态"
value={source.enabled && source.sendCallbackEnabled ? '已启用' : '停用'}
detail={source.enabled ? '配置启用' : '配置停用'}
/>
<MetricCard
label="accessToken"
value={accessTokenStatus.label}
detail={formatIndustryTokenCountdown(source.accessTokenExpiresInSeconds)}
/>
<MetricCard
label="过期时间"
value={source.accessTokenExpiresAt ? formatAdminDateTime(source.accessTokenExpiresAt) : '-'}
detail={source.lastRefreshedAt ? `刷新:${formatAdminDateTime(source.lastRefreshedAt)}` : '尚未刷新'}
/>
<MetricCard
label="refreshToken"
value={source.hasRefreshToken ? '已配置' : '未配置'}
detail={source.refreshTokenExpiresAt ? `到期:${formatAdminDateTime(source.refreshTokenExpiresAt)}` : source.refreshTokenMasked || '-'}
/>
</div>
<Card
title="快手行业电子凭证"
extra={
<Space wrap>
<Typography.Text type="secondary">{config.filePath || '默认配置'}</Typography.Text>
<Button
icon={<ReloadOutlined />}
loading={refreshing}
disabled={saving}
onClick={refreshToken}
>
token
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
disabled={refreshing}
onClick={saveConfig}
>
</Button>
</Space>
}
>
<Space wrap size={[8, 8]} className="platform-section-gap">
<Tag color={accessTokenStatus.color}>{accessTokenStatus.label}</Tag>
{source.accessTokenMasked ? <Tag>{source.accessTokenMasked}</Tag> : null}
{source.refreshTokenMasked ? <Tag>{source.refreshTokenMasked}</Tag> : null}
</Space>
<div className="platform-form-grid">
<FieldSwitch
label="启用配置"
checked={source.enabled}
onChange={(enabled) => updateSource({ enabled })}
/>
<FieldSwitch
label="通知快手回调"
checked={source.sendCallbackEnabled}
onChange={(sendCallbackEnabled) => updateSource({ sendCallbackEnabled })}
/>
<LabeledInput
label="开放平台 API"
value={source.baseUrl}
onChange={(baseUrl) => updateSource({ baseUrl })}
/>
<LabeledInput
label="授权 API"
value={source.authBaseUrl}
onChange={(authBaseUrl) => updateSource({ authBaseUrl })}
/>
<LabeledInput
label="appKey"
value={source.appKey}
onChange={(appKey) => updateSource({ appKey })}
/>
{renderSecretInput('appSecret', 'appSecret', source.appSecretMasked)}
{renderSecretInput('signSecret', 'signSecret', source.signSecretMasked)}
{renderSecretInput('messageSecret', 'messageSecret', source.messageSecretMasked)}
</div>
<Card size="small" title="Token" className="platform-section-gap">
<div className="platform-form-grid">
{renderSecretInput('accessToken', 'accessToken', source.accessTokenMasked)}
{renderSecretInput('refreshToken', 'refreshToken', source.refreshTokenMasked)}
<LabeledInput
label="accessToken 过期时间"
value={source.accessTokenExpiresAt}
onChange={(accessTokenExpiresAt) => updateSource({ accessTokenExpiresAt })}
/>
<LabeledInput
label="refreshToken 过期时间"
value={source.refreshTokenExpiresAt}
onChange={(refreshTokenExpiresAt) => updateSource({ refreshTokenExpiresAt })}
/>
<LabeledInput
label="sellerId"
value={source.sellerId}
onChange={(sellerId) => updateSource({ sellerId })}
/>
<LabeledInput
label="版本"
value={source.version}
onChange={(version) => updateSource({ version })}
/>
</div>
</Card>
</Card>
</section>
)
}
function KuaishouFeifeiPlatformPanel({ function KuaishouFeifeiPlatformPanel({
config, config,
onChange, onChange,
@@ -1483,6 +1701,43 @@ function CloudtentaclesPlatformPanel({
) )
} }
function resolveIndustryAccessTokenStatus(source: AdminKuaishouIndustrySourceConfig) {
switch (source.accessTokenStatus) {
case 'valid':
return { label: '有效', color: 'green' }
case 'expiring':
return { label: '即将过期', color: 'orange' }
case 'expired':
return { label: '已过期', color: 'red' }
case 'unknown':
return { label: '未知', color: 'blue' }
case 'missing':
default:
return { label: '未配置', color: 'default' }
}
}
function formatIndustryTokenCountdown(value: number | null) {
if (value === null || value === undefined) {
return '未记录过期时间'
}
if (value <= 0) {
return '已过期'
}
const days = Math.floor(value / 86_400)
const hours = Math.floor((value % 86_400) / 3_600)
const minutes = Math.floor((value % 3_600) / 60)
if (days > 0) {
return `${days}${hours} 小时后过期`
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟后过期`
}
return `${minutes} 分钟后过期`
}
function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) { function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) {
return ( return (
<Card className="metric-card"> <Card className="metric-card">
@@ -2,5 +2,6 @@ export * from './notifications'
export * from './scheduled-jobs' export * from './scheduled-jobs'
export * from './ninetyone' export * from './ninetyone'
export * from './kuaishou-eticket' export * from './kuaishou-eticket'
export * from './kuaishou-industry'
export * from './kuaishou-feifei' export * from './kuaishou-feifei'
export * from './cloudtentacles' export * from './cloudtentacles'
@@ -0,0 +1,28 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminKuaishouIndustryConfigResponse,
AdminKuaishouIndustryRefreshTokenResponse,
AdminKuaishouIndustrySourceConfig,
} from '@/types/admin'
export function fetchAdminKuaishouIndustrySourceConfig() {
return apiGet<AdminKuaishouIndustryConfigResponse>(
'/api/v1/admin/platform-config/kuaishou-industry-source',
)
}
export function saveAdminKuaishouIndustrySourceConfig(
payload: AdminKuaishouIndustrySourceConfig,
) {
return apiPost<AdminKuaishouIndustryConfigResponse>(
'/api/v1/admin/platform-config/kuaishou-industry-source',
payload,
)
}
export function refreshAdminKuaishouIndustryAccessToken() {
return apiPost<AdminKuaishouIndustryRefreshTokenResponse>(
'/api/v1/admin/platform-config/kuaishou-industry-source/refresh-token',
{},
)
}
+4
View File
@@ -50,6 +50,10 @@ export type {
AdminKuaishouEticketShopInfoResult, AdminKuaishouEticketShopInfoResult,
AdminKuaishouEticketDetailResult, AdminKuaishouEticketDetailResult,
AdminKuaishouEticketConsumeResult, AdminKuaishouEticketConsumeResult,
AdminKuaishouIndustryAccessTokenStatus,
AdminKuaishouIndustrySourceConfig,
AdminKuaishouIndustryConfigResponse,
AdminKuaishouIndustryRefreshTokenResponse,
AdminKuaishouFeifeiProductRule, AdminKuaishouFeifeiProductRule,
AdminKuaishouFeifeiConfig, AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiEffectiveConfig, AdminKuaishouFeifeiEffectiveConfig,
@@ -30,6 +30,13 @@ export type {
AdminKuaishouEticketConsumeResult, AdminKuaishouEticketConsumeResult,
} from './kuaishou-eticket' } from './kuaishou-eticket'
export type {
AdminKuaishouIndustryAccessTokenStatus,
AdminKuaishouIndustrySourceConfig,
AdminKuaishouIndustryConfigResponse,
AdminKuaishouIndustryRefreshTokenResponse,
} from './kuaishou-industry'
export type { export type {
AdminKuaishouFeifeiProductRule, AdminKuaishouFeifeiProductRule,
AdminKuaishouFeifeiConfig, AdminKuaishouFeifeiConfig,
@@ -0,0 +1,50 @@
export type AdminKuaishouIndustryAccessTokenStatus =
| 'missing'
| 'unknown'
| 'expired'
| 'expiring'
| 'valid'
export interface AdminKuaishouIndustrySourceConfig {
enabled: boolean
sendCallbackEnabled: boolean
baseUrl: string
authBaseUrl: string
appKey: string
appSecret: string
appSecretMasked: string
hasAppSecret: boolean
signSecret: string
signSecretMasked: string
hasSignSecret: boolean
messageSecret: string
messageSecretMasked: string
hasMessageSecret: boolean
accessToken: string
accessTokenMasked: string
hasAccessToken: boolean
refreshToken: string
refreshTokenMasked: string
hasRefreshToken: boolean
accessTokenExpiresAt: string
refreshTokenExpiresAt: string
accessTokenStatus: AdminKuaishouIndustryAccessTokenStatus
accessTokenExpiresInSeconds: number | null
sellerId: string
provider: string
platform: string
shopId: string
shopName: string
version: string
lastRefreshedAt: string
lastRefreshError: string
}
export interface AdminKuaishouIndustryConfigResponse {
filePath: string
source: AdminKuaishouIndustrySourceConfig
}
export interface AdminKuaishouIndustryRefreshTokenResponse extends AdminKuaishouIndustryConfigResponse {
refreshed: boolean
}
+59 -3
View File
@@ -33,8 +33,6 @@ services:
context: . context: .
dockerfile: deploy/docker/backend-dev.Dockerfile dockerfile: deploy/docker/backend-dev.Dockerfile
restart: unless-stopped restart: unless-stopped
env_file:
- .env
command: >- command: >-
sh /app/scripts/start-dev-container.sh sh /app/scripts/start-dev-container.sh
depends_on: depends_on:
@@ -54,13 +52,71 @@ services:
DATABASE_SSL: ${DATABASE_SSL:-false} DATABASE_SSL: ${DATABASE_SSL:-false}
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10}
DATA_ROOT: /app/data DATA_ROOT: /app/data
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
CLAIM_TOKEN_TTL_HOURS: ${CLAIM_TOKEN_TTL_HOURS:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_SESSION_TTL_HOURS: ${ADMIN_SESSION_TTL_HOURS:-}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
KAQUAN91_USER_ID: ${KAQUAN91_USER_ID:-}
KAQUAN91_SECRET: ${KAQUAN91_SECRET:-}
KAQUAN91_VERSION: ${KAQUAN91_VERSION:-}
KAQUAN91_SHOP_ID: ${KAQUAN91_SHOP_ID:-}
KAQUAN91_SHOP_NAME: ${KAQUAN91_SHOP_NAME:-}
KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS: ${KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS:-}
KAQUAN91_CARDS_ENCODING: ${KAQUAN91_CARDS_ENCODING:-}
CLOUDTENTACLES_BASE_URL: ${CLOUDTENTACLES_BASE_URL:-}
CLOUDTENTACLES_TIMEOUT_MS: ${CLOUDTENTACLES_TIMEOUT_MS:-}
CLOUDTENTACLES_SEND_SMS_PATH: ${CLOUDTENTACLES_SEND_SMS_PATH:-}
CLOUDTENTACLES_LOGIN_PATH: ${CLOUDTENTACLES_LOGIN_PATH:-}
CLOUDTENTACLES_USER_INFO_PATH: ${CLOUDTENTACLES_USER_INFO_PATH:-}
CLOUDTENTACLES_ASSET_PATH: ${CLOUDTENTACLES_ASSET_PATH:-}
CLOUDTENTACLES_PERMISSION_PATH: ${CLOUDTENTACLES_PERMISSION_PATH:-}
CLOUDTENTACLES_CATEGORIES_PATH: ${CLOUDTENTACLES_CATEGORIES_PATH:-}
CLOUDTENTACLES_SKU_LIST_PATH: ${CLOUDTENTACLES_SKU_LIST_PATH:-}
CLOUDTENTACLES_SKU_BUY_PATH: ${CLOUDTENTACLES_SKU_BUY_PATH:-}
CLOUDTENTACLES_KNAPSACK_PATH: ${CLOUDTENTACLES_KNAPSACK_PATH:-}
CLOUDTENTACLES_VN_LIST_PATH: ${CLOUDTENTACLES_VN_LIST_PATH:-}
CLOUDTENTACLES_VN_APPOINT_PATH: ${CLOUDTENTACLES_VN_APPOINT_PATH:-}
CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH: ${CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH:-}
CLOUDTENTACLES_VN_VERIF_CODE_PATH: ${CLOUDTENTACLES_VN_VERIF_CODE_PATH:-}
CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH: ${CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH:-}
CLOUDTENTACLES_VN_BIND_URL_PATH: ${CLOUDTENTACLES_VN_BIND_URL_PATH:-}
CLOUDTENTACLES_VN_BIND_INFO_PATH: ${CLOUDTENTACLES_VN_BIND_INFO_PATH:-}
CLOUDTENTACLES_VN_BACK_PATH: ${CLOUDTENTACLES_VN_BACK_PATH:-}
CLOUDTENTACLES_BIND_URL_TTL_SECONDS: ${CLOUDTENTACLES_BIND_URL_TTL_SECONDS:-}
CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS: ${CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS:-}
CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS: ${CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS:-}
CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT: ${CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT:-}
CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT: ${CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT:-}
CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID: ${CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID:-}
CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID: ${CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID:-}
CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN: ${CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN:-}
CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL: ${CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL:-}
CLOUDTENTACLES_BIND_URL_PROBE_REFERER: ${CLOUDTENTACLES_BIND_URL_PROBE_REFERER:-}
CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE: ${CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE:-}
CLOUDTENTACLES_PUBLIC_KEY_PEM: ${CLOUDTENTACLES_PUBLIC_KEY_PEM:-}
CLOUDTENTACLES_CLIENT_SOURCE: ${CLOUDTENTACLES_CLIENT_SOURCE:-}
CLOUDTENTACLES_DEVICE_ID: ${CLOUDTENTACLES_DEVICE_ID:-}
CLOUDTENTACLES_DEVICE_TYPE: ${CLOUDTENTACLES_DEVICE_TYPE:-}
KUASHOU_INDUSTRY_APP_KEY: ${KUASHOU_INDUSTRY_APP_KEY:-}
KUASHOU_INDUSTRY_APP_SECRET: ${KUASHOU_INDUSTRY_APP_SECRET:-}
KUASHOU_INDUSTRY_SIGN_SECRET: ${KUASHOU_INDUSTRY_SIGN_SECRET:-}
KUASHOU_INDUSTRY_MESSAGE_SECRET: ${KUASHOU_INDUSTRY_MESSAGE_SECRET:-}
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
KUASHOU_INDUSTRY_ACCESS_TOKEN: ${KUASHOU_INDUSTRY_ACCESS_TOKEN:-}
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED: ${KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED:-false} KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED: ${KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED:-false}
KUASHOU_INDUSTRY_PROVIDER: ${KUASHOU_INDUSTRY_PROVIDER:-}
KUASHOU_INDUSTRY_SHOP_ID: ${KUASHOU_INDUSTRY_SHOP_ID:-}
KUASHOU_INDUSTRY_SHOP_NAME: ${KUASHOU_INDUSTRY_SHOP_NAME:-}
KUASHOU_INDUSTRY_VERSION: ${KUASHOU_INDUSTRY_VERSION:-}
KUAISHOU_FEIFEI_BASE_URL: ${KUAISHOU_FEIFEI_BASE_URL:-}
KUAISHOU_FEIFEI_APP_KEY: ${KUAISHOU_FEIFEI_APP_KEY:-}
KUAISHOU_FEIFEI_APP_SECRET: ${KUAISHOU_FEIFEI_APP_SECRET:-}
KUAISHOU_FEIFEI_TIMEOUT_MS: ${KUAISHOU_FEIFEI_TIMEOUT_MS:-}
KUAISHOU_FEIFEI_NOTIFY_URL: ${KUAISHOU_FEIFEI_NOTIFY_URL:-}
KUAISHOU_FEIFEI_PRODUCT_RULES_JSON: ${KUAISHOU_FEIFEI_PRODUCT_RULES_JSON:-}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
logging: *json-log-rotation logging: *json-log-rotation
volumes: volumes:
- ./apps/backend:/app - ./apps/backend:/app
+59 -3
View File
@@ -31,8 +31,6 @@ services:
context: . context: .
dockerfile: deploy/docker/backend.Dockerfile dockerfile: deploy/docker/backend.Dockerfile
restart: unless-stopped restart: unless-stopped
env_file:
- .env
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -50,13 +48,71 @@ services:
DATABASE_SSL: ${DATABASE_SSL:-false} DATABASE_SSL: ${DATABASE_SSL:-false}
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20}
DATA_ROOT: /app/data DATA_ROOT: /app/data
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
CLAIM_TOKEN_TTL_HOURS: ${CLAIM_TOKEN_TTL_HOURS:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_SESSION_TTL_HOURS: ${ADMIN_SESSION_TTL_HOURS:-}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
KAQUAN91_USER_ID: ${KAQUAN91_USER_ID:-}
KAQUAN91_SECRET: ${KAQUAN91_SECRET:-}
KAQUAN91_VERSION: ${KAQUAN91_VERSION:-}
KAQUAN91_SHOP_ID: ${KAQUAN91_SHOP_ID:-}
KAQUAN91_SHOP_NAME: ${KAQUAN91_SHOP_NAME:-}
KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS: ${KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS:-}
KAQUAN91_CARDS_ENCODING: ${KAQUAN91_CARDS_ENCODING:-}
CLOUDTENTACLES_BASE_URL: ${CLOUDTENTACLES_BASE_URL:-}
CLOUDTENTACLES_TIMEOUT_MS: ${CLOUDTENTACLES_TIMEOUT_MS:-}
CLOUDTENTACLES_SEND_SMS_PATH: ${CLOUDTENTACLES_SEND_SMS_PATH:-}
CLOUDTENTACLES_LOGIN_PATH: ${CLOUDTENTACLES_LOGIN_PATH:-}
CLOUDTENTACLES_USER_INFO_PATH: ${CLOUDTENTACLES_USER_INFO_PATH:-}
CLOUDTENTACLES_ASSET_PATH: ${CLOUDTENTACLES_ASSET_PATH:-}
CLOUDTENTACLES_PERMISSION_PATH: ${CLOUDTENTACLES_PERMISSION_PATH:-}
CLOUDTENTACLES_CATEGORIES_PATH: ${CLOUDTENTACLES_CATEGORIES_PATH:-}
CLOUDTENTACLES_SKU_LIST_PATH: ${CLOUDTENTACLES_SKU_LIST_PATH:-}
CLOUDTENTACLES_SKU_BUY_PATH: ${CLOUDTENTACLES_SKU_BUY_PATH:-}
CLOUDTENTACLES_KNAPSACK_PATH: ${CLOUDTENTACLES_KNAPSACK_PATH:-}
CLOUDTENTACLES_VN_LIST_PATH: ${CLOUDTENTACLES_VN_LIST_PATH:-}
CLOUDTENTACLES_VN_APPOINT_PATH: ${CLOUDTENTACLES_VN_APPOINT_PATH:-}
CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH: ${CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH:-}
CLOUDTENTACLES_VN_VERIF_CODE_PATH: ${CLOUDTENTACLES_VN_VERIF_CODE_PATH:-}
CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH: ${CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH:-}
CLOUDTENTACLES_VN_BIND_URL_PATH: ${CLOUDTENTACLES_VN_BIND_URL_PATH:-}
CLOUDTENTACLES_VN_BIND_INFO_PATH: ${CLOUDTENTACLES_VN_BIND_INFO_PATH:-}
CLOUDTENTACLES_VN_BACK_PATH: ${CLOUDTENTACLES_VN_BACK_PATH:-}
CLOUDTENTACLES_BIND_URL_TTL_SECONDS: ${CLOUDTENTACLES_BIND_URL_TTL_SECONDS:-}
CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS: ${CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS:-}
CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS: ${CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS:-}
CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT: ${CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT:-}
CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT: ${CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT:-}
CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID: ${CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID:-}
CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID: ${CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID:-}
CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN: ${CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN:-}
CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL: ${CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL:-}
CLOUDTENTACLES_BIND_URL_PROBE_REFERER: ${CLOUDTENTACLES_BIND_URL_PROBE_REFERER:-}
CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE: ${CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE:-}
CLOUDTENTACLES_PUBLIC_KEY_PEM: ${CLOUDTENTACLES_PUBLIC_KEY_PEM:-}
CLOUDTENTACLES_CLIENT_SOURCE: ${CLOUDTENTACLES_CLIENT_SOURCE:-}
CLOUDTENTACLES_DEVICE_ID: ${CLOUDTENTACLES_DEVICE_ID:-}
CLOUDTENTACLES_DEVICE_TYPE: ${CLOUDTENTACLES_DEVICE_TYPE:-}
KUASHOU_INDUSTRY_APP_KEY: ${KUASHOU_INDUSTRY_APP_KEY:-}
KUASHOU_INDUSTRY_APP_SECRET: ${KUASHOU_INDUSTRY_APP_SECRET:-}
KUASHOU_INDUSTRY_SIGN_SECRET: ${KUASHOU_INDUSTRY_SIGN_SECRET:-}
KUASHOU_INDUSTRY_MESSAGE_SECRET: ${KUASHOU_INDUSTRY_MESSAGE_SECRET:-}
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
KUASHOU_INDUSTRY_ACCESS_TOKEN: ${KUASHOU_INDUSTRY_ACCESS_TOKEN:-}
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED: ${KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED:-false} KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED: ${KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED:-false}
KUASHOU_INDUSTRY_PROVIDER: ${KUASHOU_INDUSTRY_PROVIDER:-}
KUASHOU_INDUSTRY_SHOP_ID: ${KUASHOU_INDUSTRY_SHOP_ID:-}
KUASHOU_INDUSTRY_SHOP_NAME: ${KUASHOU_INDUSTRY_SHOP_NAME:-}
KUASHOU_INDUSTRY_VERSION: ${KUASHOU_INDUSTRY_VERSION:-}
KUAISHOU_FEIFEI_BASE_URL: ${KUAISHOU_FEIFEI_BASE_URL:-}
KUAISHOU_FEIFEI_APP_KEY: ${KUAISHOU_FEIFEI_APP_KEY:-}
KUAISHOU_FEIFEI_APP_SECRET: ${KUAISHOU_FEIFEI_APP_SECRET:-}
KUAISHOU_FEIFEI_TIMEOUT_MS: ${KUAISHOU_FEIFEI_TIMEOUT_MS:-}
KUAISHOU_FEIFEI_NOTIFY_URL: ${KUAISHOU_FEIFEI_NOTIFY_URL:-}
KUAISHOU_FEIFEI_PRODUCT_RULES_JSON: ${KUAISHOU_FEIFEI_PRODUCT_RULES_JSON:-}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
logging: *json-log-rotation logging: *json-log-rotation
volumes: volumes:
- ./apps/backend/data:/app/data - ./apps/backend/data:/app/data