diff --git a/.env.server.example b/.env.server.example index 219424a8..7dcfa755 100644 --- a/.env.server.example +++ b/.env.server.example @@ -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 diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index a4d390ac..d5c135aa 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -49,7 +49,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { appSecret: "", signSecret: "", messageSecret: "", - accessToken: "", sendCallbackEnabled: false, provider: "kuaishou-industry", platform: "kuaishou", diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index b191836f..12b4b92a 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -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", diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 0fa896f2..e5296820 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -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); diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts new file mode 100644 index 00000000..d86c5f03 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts @@ -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 diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts new file mode 100644 index 00000000..1203d538 --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts @@ -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 + +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 { + const patch: Partial = {} + 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) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/config.ts b/apps/backend/src/services/platforms/kuaishou-industry/config.ts index ae63c077..f541c8bf 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/config.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/config.ts @@ -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 = {}) { +export function getKuaishouIndustryConfig(overrides: Partial = {}) { 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, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts index 1223aa24..cfbd9b31 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts @@ -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 -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 +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts index de6abf3e..973b1139 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts @@ -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 -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 +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts index c95eda63..42e75efd 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts @@ -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 -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 +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts new file mode 100644 index 00000000..c9b3a887 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts @@ -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 + +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 { + 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]' +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts b/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts new file mode 100644 index 00000000..a0524101 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts @@ -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 { + 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, + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts new file mode 100644 index 00000000..7667d4a1 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts @@ -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 + +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 { + 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 { + 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 + 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]' +} diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index ba0e22e8..669e5fab 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -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 diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index 26bb6362..5fc19740 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -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 diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 07cdb5f5..337ae897 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -92,7 +92,6 @@ export type RuntimeConfig = { appSecret: string; signSecret: string; messageSecret: string; - accessToken: string; sendCallbackEnabled: boolean; provider: string; platform: string; diff --git a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx index 2faaa117..a6737a73 100644 --- a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx +++ b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx @@ -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(null) const [feifeiConfig, setFeifeiConfig] = useState(null) const [cloudtentaclesConfig, setCloudtentaclesConfig] = useState(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() { ), }, + { + key: 'kuaishouIndustry', + label: '行业电子凭证', + children: industryConfig ? ( + + ) : ( + + ), + }, { 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) { + onChange({ + ...config, + source: { + ...source, + ...patch, + }, + }) + } + + function renderSecretInput( + label: string, + field: 'appSecret' | 'signSecret' | 'messageSecret' | 'accessToken' | 'refreshToken', + masked: string, + ) { + return ( +
+ {label} + updateSource({ [field]: event.target.value })} + /> +
+ ) + } + + const accessTokenStatus = resolveIndustryAccessTokenStatus(source) + + return ( +
+ {source.lastRefreshError ? ( + + ) : null} + +
+ + + + +
+ + + {config.filePath || '默认配置'} + + + + } + > + + {accessTokenStatus.label} + {source.accessTokenMasked ? {source.accessTokenMasked} : null} + {source.refreshTokenMasked ? {source.refreshTokenMasked} : null} + + +
+ updateSource({ enabled })} + /> + updateSource({ sendCallbackEnabled })} + /> + updateSource({ baseUrl })} + /> + updateSource({ authBaseUrl })} + /> + updateSource({ appKey })} + /> + {renderSecretInput('appSecret', 'appSecret', source.appSecretMasked)} + {renderSecretInput('signSecret', 'signSecret', source.signSecretMasked)} + {renderSecretInput('messageSecret', 'messageSecret', source.messageSecretMasked)} +
+ + +
+ {renderSecretInput('accessToken', 'accessToken', source.accessTokenMasked)} + {renderSecretInput('refreshToken', 'refreshToken', source.refreshTokenMasked)} + updateSource({ accessTokenExpiresAt })} + /> + updateSource({ refreshTokenExpiresAt })} + /> + updateSource({ sellerId })} + /> + updateSource({ version })} + /> +
+
+
+
+ ) +} + 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 ( diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index 9f73f850..1b0af90f 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -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' diff --git a/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts b/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts new file mode 100644 index 00000000..af300e2d --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts @@ -0,0 +1,28 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { + AdminKuaishouIndustryConfigResponse, + AdminKuaishouIndustryRefreshTokenResponse, + AdminKuaishouIndustrySourceConfig, +} from '@/types/admin' + +export function fetchAdminKuaishouIndustrySourceConfig() { + return apiGet( + '/api/v1/admin/platform-config/kuaishou-industry-source', + ) +} + +export function saveAdminKuaishouIndustrySourceConfig( + payload: AdminKuaishouIndustrySourceConfig, +) { + return apiPost( + '/api/v1/admin/platform-config/kuaishou-industry-source', + payload, + ) +} + +export function refreshAdminKuaishouIndustryAccessToken() { + return apiPost( + '/api/v1/admin/platform-config/kuaishou-industry-source/refresh-token', + {}, + ) +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index db08037a..fec9a362 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -50,6 +50,10 @@ export type { AdminKuaishouEticketShopInfoResult, AdminKuaishouEticketDetailResult, AdminKuaishouEticketConsumeResult, + AdminKuaishouIndustryAccessTokenStatus, + AdminKuaishouIndustrySourceConfig, + AdminKuaishouIndustryConfigResponse, + AdminKuaishouIndustryRefreshTokenResponse, AdminKuaishouFeifeiProductRule, AdminKuaishouFeifeiConfig, AdminKuaishouFeifeiEffectiveConfig, diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 560c4c6d..77033df2 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -30,6 +30,13 @@ export type { AdminKuaishouEticketConsumeResult, } from './kuaishou-eticket' +export type { + AdminKuaishouIndustryAccessTokenStatus, + AdminKuaishouIndustrySourceConfig, + AdminKuaishouIndustryConfigResponse, + AdminKuaishouIndustryRefreshTokenResponse, +} from './kuaishou-industry' + export type { AdminKuaishouFeifeiProductRule, AdminKuaishouFeifeiConfig, diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts new file mode 100644 index 00000000..213f3c7d --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts @@ -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 +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cc972148..46f34ac8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 00ad21ac..9a3516cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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