增加行业凭证后台 token 配置
This commit is contained in:
@@ -33,7 +33,6 @@ KUASHOU_INDUSTRY_APP_KEY=
|
||||
KUASHOU_INDUSTRY_APP_SECRET=
|
||||
KUASHOU_INDUSTRY_SIGN_SECRET=
|
||||
KUASHOU_INDUSTRY_MESSAGE_SECRET=
|
||||
KUASHOU_INDUSTRY_ACCESS_TOKEN=
|
||||
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=false
|
||||
|
||||
# Cloudtentacles
|
||||
|
||||
@@ -49,7 +49,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
appSecret: "",
|
||||
signSecret: "",
|
||||
messageSecret: "",
|
||||
accessToken: "",
|
||||
sendCallbackEnabled: false,
|
||||
provider: "kuaishou-industry",
|
||||
platform: "kuaishou",
|
||||
|
||||
@@ -246,11 +246,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
"kuaishouIndustry",
|
||||
"messageSecret",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_ACCESS_TOKEN", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"accessToken",
|
||||
]),
|
||||
booleanEnv("KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
|
||||
import { requireAdminRoles } from "./session.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 kuaishouEticketRouter from "./platform-config/kuaishou-eticket.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", notificationsRouter);
|
||||
router.use("/platform-config", kuaishouIndustryRouter);
|
||||
router.use("/platform-config", kuaishouEticketRouter);
|
||||
router.use("/platform-config", kuaishouFeifeiRouter);
|
||||
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 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_PLATFORM = 'kuaishou'
|
||||
|
||||
type KuaishouIndustryRuntimeConfig = RuntimeConfig['platforms']['kuaishouIndustry']
|
||||
|
||||
export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustryRuntimeConfig> = {}) {
|
||||
export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustrySourceConfig> = {}) {
|
||||
const config = {
|
||||
...(runtimeConfig.platforms?.kuaishouIndustry || {}),
|
||||
...getKuaishouIndustrySourceConfig(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || '').trim(),
|
||||
authBaseUrl: String(config.authBaseUrl || '').trim(),
|
||||
appKey: String(config.appKey || '').trim(),
|
||||
appSecret: String(config.appSecret || '').trim(),
|
||||
signSecret: String(config.signSecret || '').trim(),
|
||||
messageSecret: String(config.messageSecret || '').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),
|
||||
provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||
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 { getKuaishouIndustryConfig } from './config.js'
|
||||
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
|
||||
type ConsumeCallbackInput = {
|
||||
oid: string
|
||||
@@ -32,13 +33,28 @@ type ConsumeCallbackInput = {
|
||||
}
|
||||
|
||||
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]', '回调未启用,跳过')
|
||||
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) {
|
||||
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 未配置,跳过')
|
||||
return { success: true }
|
||||
@@ -89,7 +105,7 @@ export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ su
|
||||
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}`, {
|
||||
url,
|
||||
@@ -182,3 +198,7 @@ function resolveCallbackErrorDetail(error: unknown): JsonObject {
|
||||
|
||||
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 { getKuaishouIndustryConfig } from './config.js'
|
||||
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
|
||||
type DestroyCallbackInput = {
|
||||
oid: string
|
||||
@@ -22,13 +23,28 @@ type DestroyCallbackInput = {
|
||||
}
|
||||
|
||||
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]', '回调未启用,跳过')
|
||||
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) {
|
||||
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 未配置,跳过')
|
||||
return { success: true }
|
||||
@@ -72,7 +88,7 @@ export async function destroyCallback(input: DestroyCallbackInput): Promise<{ su
|
||||
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}`, {
|
||||
url,
|
||||
@@ -164,3 +180,7 @@ function resolveCallbackErrorDetail(error: unknown): JsonObject {
|
||||
|
||||
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 { getKuaishouIndustryConfig } from './config.js'
|
||||
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
|
||||
type SendCallbackInput = {
|
||||
oid: string
|
||||
@@ -28,13 +29,28 @@ type SendCallbackInput = {
|
||||
}
|
||||
|
||||
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]', '发码回调未启用,跳过')
|
||||
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) {
|
||||
logWarn('[kuaishou-industry/send-callback]', 'accessToken 未配置,跳过发码回调')
|
||||
return { success: true }
|
||||
@@ -84,7 +100,7 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
|
||||
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}`, {
|
||||
url,
|
||||
@@ -176,3 +192,7 @@ function resolveCallbackErrorDetail(error: unknown): JsonObject {
|
||||
|
||||
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,
|
||||
AdminCloudtentaclesSkuUseInput,
|
||||
AdminCloudtentaclesSourceConfigInput,
|
||||
AdminKuaishouIndustrySourceConfigInput,
|
||||
AdminCloudtentaclesTestLoginInput,
|
||||
AdminCloudtentaclesValidateSessionInput,
|
||||
AdminCloudtentaclesVirtualNumberInput,
|
||||
@@ -31,6 +32,7 @@ export type AdminTaskRouteQuery = AdminTaskListQueryInput
|
||||
export type AdminRouteAdminSession = AdminViewerSessionInput
|
||||
|
||||
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
|
||||
export type AdminKuaishouIndustrySourceConfigRouteBody = AdminKuaishouIndustrySourceConfigInput
|
||||
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
|
||||
export type AdminNotificationTestRouteBody = AdminNotificationTestInput
|
||||
export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput
|
||||
|
||||
@@ -12,6 +12,27 @@ export type AdminKuaishouEticketSourceConfigInput = {
|
||||
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 = {
|
||||
id?: string
|
||||
name?: string
|
||||
|
||||
@@ -92,7 +92,6 @@ export type RuntimeConfig = {
|
||||
appSecret: string;
|
||||
signSecret: string;
|
||||
messageSecret: string;
|
||||
accessToken: string;
|
||||
sendCallbackEnabled: boolean;
|
||||
provider: string;
|
||||
platform: string;
|
||||
|
||||
@@ -38,17 +38,20 @@ import {
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
fetchAdminKuaishouEticketSourceConfig,
|
||||
fetchAdminKuaishouFeifeiConfig,
|
||||
fetchAdminKuaishouIndustrySourceConfig,
|
||||
fetchAdminNinetyoneOrders,
|
||||
fetchAdminNotificationConfig,
|
||||
fetchAdminScheduledJobsConfig,
|
||||
matchAdminKuaishouFeifeiProduct,
|
||||
queryAdminKuaishouEticketDetail,
|
||||
queryAdminKuaishouEticketShopInfo,
|
||||
refreshAdminKuaishouIndustryAccessToken,
|
||||
retryAdminNinetyoneOrder,
|
||||
runAdminScheduledJob,
|
||||
saveAdminCloudtentaclesSourceConfig,
|
||||
saveAdminKuaishouEticketSourceConfig,
|
||||
saveAdminKuaishouFeifeiConfig,
|
||||
saveAdminKuaishouIndustrySourceConfig,
|
||||
saveAdminNotificationConfig,
|
||||
saveAdminScheduledJobsConfig,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
@@ -64,6 +67,8 @@ import type {
|
||||
AdminKuaishouEticketShopConfigItem,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketSourceConfig,
|
||||
AdminKuaishouIndustryConfigResponse,
|
||||
AdminKuaishouIndustrySourceConfig,
|
||||
AdminKuaishouFeifeiConfig,
|
||||
AdminKuaishouFeifeiConfigResponse,
|
||||
AdminKuaishouFeifeiMatchResult,
|
||||
@@ -83,6 +88,7 @@ type PlatformTab =
|
||||
| 'ninetyone'
|
||||
| 'notifications'
|
||||
| 'kuaishouEticket'
|
||||
| 'kuaishouIndustry'
|
||||
| 'kuaishouFeifei'
|
||||
| 'cloudtentacles'
|
||||
|
||||
@@ -110,6 +116,7 @@ const platformTabs: PlatformTab[] = [
|
||||
'ninetyone',
|
||||
'notifications',
|
||||
'kuaishouEticket',
|
||||
'kuaishouIndustry',
|
||||
'kuaishouFeifei',
|
||||
'cloudtentacles',
|
||||
]
|
||||
@@ -125,6 +132,7 @@ export default function AdminPlatformShopsPage() {
|
||||
filePath: string
|
||||
source: AdminKuaishouEticketSourceConfig
|
||||
} | null>(null)
|
||||
const [industryConfig, setIndustryConfig] = useState<AdminKuaishouIndustryConfigResponse | null>(null)
|
||||
const [feifeiConfig, setFeifeiConfig] = useState<AdminKuaishouFeifeiConfigResponse | null>(null)
|
||||
const [cloudtentaclesConfig, setCloudtentaclesConfig] =
|
||||
useState<AdminCloudtentaclesSourceConfigResponse | null>(null)
|
||||
@@ -148,6 +156,7 @@ export default function AdminPlatformShopsPage() {
|
||||
notificationResponse,
|
||||
scheduledJobsResponse,
|
||||
eticketResponse,
|
||||
industryResponse,
|
||||
feifeiResponse,
|
||||
cloudtentaclesResponse,
|
||||
ninetyoneResponse,
|
||||
@@ -155,6 +164,7 @@ export default function AdminPlatformShopsPage() {
|
||||
fetchAdminNotificationConfig(),
|
||||
fetchAdminScheduledJobsConfig(),
|
||||
fetchAdminKuaishouEticketSourceConfig(),
|
||||
fetchAdminKuaishouIndustrySourceConfig(),
|
||||
fetchAdminKuaishouFeifeiConfig(),
|
||||
fetchAdminCloudtentaclesSourceConfig(),
|
||||
fetchAdminNinetyoneOrders({ page: 1, pageSize: 20 }),
|
||||
@@ -163,6 +173,7 @@ export default function AdminPlatformShopsPage() {
|
||||
setNotificationConfig(notificationResponse.data)
|
||||
setScheduledJobs(scheduledJobsResponse.data)
|
||||
setEticketConfig(eticketResponse.data)
|
||||
setIndustryConfig(industryResponse.data)
|
||||
setFeifeiConfig(feifeiResponse.data)
|
||||
setCloudtentaclesConfig(cloudtentaclesResponse.data)
|
||||
setNinetyoneData(ninetyoneResponse.data)
|
||||
@@ -243,6 +254,18 @@ export default function AdminPlatformShopsPage() {
|
||||
<Empty description="快手小店配置未加载" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kuaishouIndustry',
|
||||
label: '行业电子凭证',
|
||||
children: industryConfig ? (
|
||||
<KuaishouIndustryPanel
|
||||
config={industryConfig}
|
||||
onChange={setIndustryConfig}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="快手行业电子凭证配置未加载" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kuaishouFeifei',
|
||||
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({
|
||||
config,
|
||||
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 }) {
|
||||
return (
|
||||
<Card className="metric-card">
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './notifications'
|
||||
export * from './scheduled-jobs'
|
||||
export * from './ninetyone'
|
||||
export * from './kuaishou-eticket'
|
||||
export * from './kuaishou-industry'
|
||||
export * from './kuaishou-feifei'
|
||||
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',
|
||||
{},
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,10 @@ export type {
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminKuaishouIndustryAccessTokenStatus,
|
||||
AdminKuaishouIndustrySourceConfig,
|
||||
AdminKuaishouIndustryConfigResponse,
|
||||
AdminKuaishouIndustryRefreshTokenResponse,
|
||||
AdminKuaishouFeifeiProductRule,
|
||||
AdminKuaishouFeifeiConfig,
|
||||
AdminKuaishouFeifeiEffectiveConfig,
|
||||
|
||||
@@ -30,6 +30,13 @@ export type {
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
} from './kuaishou-eticket'
|
||||
|
||||
export type {
|
||||
AdminKuaishouIndustryAccessTokenStatus,
|
||||
AdminKuaishouIndustrySourceConfig,
|
||||
AdminKuaishouIndustryConfigResponse,
|
||||
AdminKuaishouIndustryRefreshTokenResponse,
|
||||
} from './kuaishou-industry'
|
||||
|
||||
export type {
|
||||
AdminKuaishouFeifeiProductRule,
|
||||
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
@@ -33,8 +33,6 @@ services:
|
||||
context: .
|
||||
dockerfile: deploy/docker/backend-dev.Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
command: >-
|
||||
sh /app/scripts/start-dev-container.sh
|
||||
depends_on:
|
||||
@@ -54,13 +52,71 @@ services:
|
||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10}
|
||||
DATA_ROOT: /app/data
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
|
||||
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_TTL_HOURS: ${ADMIN_SESSION_TTL_HOURS:-}
|
||||
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_ACCESS_TOKEN: ${KUASHOU_INDUSTRY_ACCESS_TOKEN:-}
|
||||
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
|
||||
volumes:
|
||||
- ./apps/backend:/app
|
||||
|
||||
+59
-3
@@ -31,8 +31,6 @@ services:
|
||||
context: .
|
||||
dockerfile: deploy/docker/backend.Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -50,13 +48,71 @@ services:
|
||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20}
|
||||
DATA_ROOT: /app/data
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
|
||||
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_TTL_HOURS: ${ADMIN_SESSION_TTL_HOURS:-}
|
||||
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_ACCESS_TOKEN: ${KUASHOU_INDUSTRY_ACCESS_TOKEN:-}
|
||||
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
|
||||
volumes:
|
||||
- ./apps/backend/data:/app/data
|
||||
|
||||
Reference in New Issue
Block a user