feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/** 告警级别 */
|
||||
export type AlertLevel = 'P0' | 'P1' | 'P2';
|
||||
|
||||
/** 支付 / 核销行为异常阈值(金额单位:元) */
|
||||
export const ALERT_THRESHOLDS = {
|
||||
payAmountMinYuan: 1,
|
||||
payAmountMaxYuan: 5000,
|
||||
payAttemptPerMinute: 3,
|
||||
payFailPerMinute: 5,
|
||||
redeemAmountMinYuan: 1,
|
||||
redeemAmountMaxYuan: 1000,
|
||||
redeemAttemptPerMinute: 3,
|
||||
redeemFailPerMinute: 5,
|
||||
} as const;
|
||||
|
||||
/** Redis 分钟桶计数 TTL(秒) */
|
||||
export const ALERT_RATE_BUCKET_TTL_SEC = 120;
|
||||
|
||||
/** 默认去重 TTL */
|
||||
export const ALERT_DEDUPE_TTL_SEC = 600;
|
||||
|
||||
/** 频次类告警去重 TTL(同一分钟桶只推一次) */
|
||||
export const ALERT_RATE_DEDUPE_TTL_SEC = 90;
|
||||
|
||||
/** 卡住订单扫描 */
|
||||
export const ALERT_STUCK_ORDER = {
|
||||
pendingShipHours: 24,
|
||||
inDeliveryHours: 48,
|
||||
sampleLimit: 5,
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { AlertService } from './alert.service';
|
||||
import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
|
||||
/**
|
||||
* 运营告警(企微 Webhook 多实例)。Global 以便 Filter / 各业务 Module 注入。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
providers: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
|
||||
exports: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
|
||||
})
|
||||
export class AlertModule {}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { WecomPushCondition } from '@dukang/shared-types';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
import { ALERT_DEDUPE_TTL_SEC, type AlertLevel } from './alert.constants';
|
||||
|
||||
export type AlertNotifyInput = {
|
||||
level: AlertLevel;
|
||||
category: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
dedupeKey: string;
|
||||
dedupeTtlSec?: number;
|
||||
/** 覆盖默认事件 key 映射 */
|
||||
eventKeys?: WecomPushCondition[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AlertService {
|
||||
private readonly logger = new Logger(AlertService.name);
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
) {}
|
||||
|
||||
/** 异步告警,不阻塞调用方 */
|
||||
notify(input: AlertNotifyInput): void {
|
||||
void this.notifyAsync(input).catch((e) => {
|
||||
this.logger.warn(`alert notify failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async notifyAsync(input: AlertNotifyInput): Promise<boolean> {
|
||||
const eventKeys = resolveAlertEventKeys(input);
|
||||
const hasAny = await Promise.all(eventKeys.map((k) => this.wecomPush.hasEnabledPushes(k)));
|
||||
if (!hasAny.some(Boolean)) return false;
|
||||
|
||||
const ttl = input.dedupeTtlSec ?? ALERT_DEDUPE_TTL_SEC;
|
||||
const dedupeRedisKey = `alert:dedupe:${input.dedupeKey}`;
|
||||
try {
|
||||
const ok = await this.redis.client.set(dedupeRedisKey, '1', 'EX', ttl, 'NX');
|
||||
if (ok !== 'OK') return false;
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`alert dedupe redis error, send anyway: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.sendMarkdownNow(input, eventKeys);
|
||||
}
|
||||
|
||||
private async sendMarkdownNow(
|
||||
input: AlertNotifyInput,
|
||||
eventKeys: WecomPushCondition[],
|
||||
): Promise<boolean> {
|
||||
const envLabel = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || 'local').trim();
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
const content = [
|
||||
`## [${input.level}] ${escapeMd(input.title)}`,
|
||||
`> 环境:<font color="comment">${escapeMd(envLabel)}</font>`,
|
||||
`> 类别:<font color="comment">${escapeMd(input.category)}</font>`,
|
||||
`> 时间:${escapeMd(now)}`,
|
||||
'',
|
||||
escapeMd(input.detail).slice(0, 3500),
|
||||
].join('\n');
|
||||
|
||||
let sent = 0;
|
||||
for (const eventKey of eventKeys) {
|
||||
sent += await this.wecomPush.dispatchMarkdown(eventKey, content, { applyMention: false });
|
||||
}
|
||||
return sent > 0;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAlertEventKeys(input: AlertNotifyInput): WecomPushCondition[] {
|
||||
if (input.eventKeys?.length) return [...new Set(input.eventKeys)];
|
||||
if (input.category === 'pay') return ['alert.pay'];
|
||||
if (input.category === 'redeem') return ['alert.redeem'];
|
||||
if (input.category === 'settlement') return ['alert.settlement'];
|
||||
if (input.category === 'ops') return ['alert.ops'];
|
||||
return ['alert.system'];
|
||||
}
|
||||
|
||||
/** 轻量转义,保留企微 markdown 的 <font> 标签可用 */
|
||||
function escapeMd(s: string): string {
|
||||
return s.replace(/([\\`*_[\]])/g, '\\$1');
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { AlertService } from './alert.service';
|
||||
import {
|
||||
ALERT_RATE_BUCKET_TTL_SEC,
|
||||
ALERT_RATE_DEDUPE_TTL_SEC,
|
||||
ALERT_THRESHOLDS,
|
||||
} from './alert.constants';
|
||||
|
||||
export type PayAnomalyMeta = {
|
||||
orderNo?: string;
|
||||
userId?: string | number | bigint;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type RedeemAnomalyMeta = {
|
||||
storeId?: string | number | bigint;
|
||||
userId?: string | number | bigint;
|
||||
recordId?: string | number | bigint;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PayRedeemAnomalyService {
|
||||
private readonly logger = new Logger(PayRedeemAnomalyService.name);
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
onPayAttempt(amountYuan: number, meta: PayAnomalyMeta = {}): void {
|
||||
this.checkPayAmount(amountYuan, meta);
|
||||
void this.bumpAndMaybeAlert(
|
||||
'pay:attempt',
|
||||
ALERT_THRESHOLDS.payAttemptPerMinute,
|
||||
'P0',
|
||||
'pay',
|
||||
'支付频率异常',
|
||||
`1 分钟内支付尝试超过 ${ALERT_THRESHOLDS.payAttemptPerMinute} 次`,
|
||||
meta,
|
||||
);
|
||||
}
|
||||
|
||||
onPaySuccess(amountYuan: number, meta: PayAnomalyMeta = {}): void {
|
||||
this.checkPayAmount(amountYuan, meta);
|
||||
}
|
||||
|
||||
onPayFail(reason: string, meta: PayAnomalyMeta = {}): void {
|
||||
void this.bumpAndMaybeAlert(
|
||||
'pay:fail',
|
||||
ALERT_THRESHOLDS.payFailPerMinute,
|
||||
'P0',
|
||||
'pay',
|
||||
'支付失败频率异常',
|
||||
`1 分钟内支付失败超过 ${ALERT_THRESHOLDS.payFailPerMinute} 次;最近原因:${reason}`,
|
||||
{ ...meta, reason },
|
||||
);
|
||||
}
|
||||
|
||||
onRedeemAttempt(amountYuan: number, meta: RedeemAnomalyMeta = {}): void {
|
||||
this.checkRedeemAmount(amountYuan, meta);
|
||||
void this.bumpAndMaybeAlert(
|
||||
'redeem:attempt',
|
||||
ALERT_THRESHOLDS.redeemAttemptPerMinute,
|
||||
'P0',
|
||||
'redeem',
|
||||
'核销频率异常',
|
||||
`1 分钟内核销尝试超过 ${ALERT_THRESHOLDS.redeemAttemptPerMinute} 次`,
|
||||
meta,
|
||||
);
|
||||
}
|
||||
|
||||
onRedeemFail(reason: string, meta: RedeemAnomalyMeta = {}): void {
|
||||
void this.bumpAndMaybeAlert(
|
||||
'redeem:fail',
|
||||
ALERT_THRESHOLDS.redeemFailPerMinute,
|
||||
'P0',
|
||||
'redeem',
|
||||
'核销失败频率异常',
|
||||
`1 分钟内核销失败超过 ${ALERT_THRESHOLDS.redeemFailPerMinute} 次;最近原因:${reason}`,
|
||||
{ ...meta, reason },
|
||||
);
|
||||
}
|
||||
|
||||
/** 弱网阈值 / 补核销待办等业务信号 */
|
||||
notifyRedeemOps(title: string, detail: string, dedupeKey: string): void {
|
||||
this.alert.notify({
|
||||
level: 'P1',
|
||||
category: 'redeem',
|
||||
title,
|
||||
detail,
|
||||
dedupeKey,
|
||||
});
|
||||
}
|
||||
|
||||
private checkPayAmount(amountYuan: number, meta: PayAnomalyMeta): void {
|
||||
if (!Number.isFinite(amountYuan)) return;
|
||||
if (amountYuan < ALERT_THRESHOLDS.payAmountMinYuan) {
|
||||
this.alert.notify({
|
||||
level: 'P1',
|
||||
category: 'pay',
|
||||
title: '支付金额过低',
|
||||
detail: formatPayDetail(
|
||||
`支付金额 ${amountYuan} 元 < ${ALERT_THRESHOLDS.payAmountMinYuan} 元`,
|
||||
meta,
|
||||
),
|
||||
dedupeKey: `pay_amount_low|${meta.orderNo ?? amountYuan}`,
|
||||
});
|
||||
} else if (amountYuan > ALERT_THRESHOLDS.payAmountMaxYuan) {
|
||||
this.alert.notify({
|
||||
level: 'P1',
|
||||
category: 'pay',
|
||||
title: '支付金额过高',
|
||||
detail: formatPayDetail(
|
||||
`支付金额 ${amountYuan} 元 > ${ALERT_THRESHOLDS.payAmountMaxYuan} 元`,
|
||||
meta,
|
||||
),
|
||||
dedupeKey: `pay_amount_high|${meta.orderNo ?? amountYuan}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private checkRedeemAmount(amountYuan: number, meta: RedeemAnomalyMeta): void {
|
||||
if (!Number.isFinite(amountYuan)) return;
|
||||
if (amountYuan < ALERT_THRESHOLDS.redeemAmountMinYuan) {
|
||||
this.alert.notify({
|
||||
level: 'P1',
|
||||
category: 'redeem',
|
||||
title: '核销金额过低',
|
||||
detail: formatRedeemDetail(
|
||||
`核销金额 ${amountYuan} 元 < ${ALERT_THRESHOLDS.redeemAmountMinYuan} 元`,
|
||||
meta,
|
||||
),
|
||||
dedupeKey: `redeem_amount_low|${meta.recordId ?? meta.storeId ?? amountYuan}`,
|
||||
});
|
||||
} else if (amountYuan > ALERT_THRESHOLDS.redeemAmountMaxYuan) {
|
||||
this.alert.notify({
|
||||
level: 'P1',
|
||||
category: 'redeem',
|
||||
title: '核销金额过高',
|
||||
detail: formatRedeemDetail(
|
||||
`核销金额 ${amountYuan} 元 > ${ALERT_THRESHOLDS.redeemAmountMaxYuan} 元`,
|
||||
meta,
|
||||
),
|
||||
dedupeKey: `redeem_amount_high|${meta.recordId ?? meta.storeId ?? amountYuan}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async bumpAndMaybeAlert(
|
||||
counterKind: string,
|
||||
threshold: number,
|
||||
level: 'P0' | 'P1' | 'P2',
|
||||
category: string,
|
||||
title: string,
|
||||
baseDetail: string,
|
||||
meta: PayAnomalyMeta & RedeemAnomalyMeta,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const bucket = Math.floor(Date.now() / 60_000);
|
||||
const key = `alert:cnt:${counterKind}:${bucket}`;
|
||||
const count = await this.redis.incr(key, ALERT_RATE_BUCKET_TTL_SEC);
|
||||
if (count <= threshold) return;
|
||||
|
||||
const extra = [
|
||||
`当前分钟计数:${count}`,
|
||||
meta.orderNo ? `订单:${meta.orderNo}` : null,
|
||||
meta.storeId != null ? `门店:${String(meta.storeId)}` : null,
|
||||
meta.userId != null ? `用户:${String(meta.userId)}` : null,
|
||||
meta.reason ? `原因:${meta.reason}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
this.alert.notify({
|
||||
level,
|
||||
category,
|
||||
title,
|
||||
detail: `${baseDetail}\n${extra}`,
|
||||
dedupeKey: `${counterKind}|${bucket}`,
|
||||
dedupeTtlSec: ALERT_RATE_DEDUPE_TTL_SEC,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`anomaly counter failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatPayDetail(line: string, meta: PayAnomalyMeta): string {
|
||||
return [
|
||||
line,
|
||||
meta.orderNo ? `订单:${meta.orderNo}` : null,
|
||||
meta.userId != null ? `用户:${String(meta.userId)}` : null,
|
||||
meta.reason ? `原因:${meta.reason}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatRedeemDetail(line: string, meta: RedeemAnomalyMeta): string {
|
||||
return [
|
||||
line,
|
||||
meta.storeId != null ? `门店:${String(meta.storeId)}` : null,
|
||||
meta.userId != null ? `用户:${String(meta.userId)}` : null,
|
||||
meta.recordId != null ? `记录:${String(meta.recordId)}` : null,
|
||||
meta.reason ? `原因:${meta.reason}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CommonEvent } from '@prisma/client';
|
||||
|
||||
type OrderLike = {
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
listUnitPrice?: unknown;
|
||||
payAmount?: unknown;
|
||||
payStatus?: string;
|
||||
payExternalNo?: string | null;
|
||||
paidAt?: Date | string | null;
|
||||
imageResource?: { url?: string } | null;
|
||||
};
|
||||
|
||||
export function mapOrderItemCompat(order: OrderLike) {
|
||||
return {
|
||||
productName: order.productName ?? '',
|
||||
productSpec: order.productSpec ?? '',
|
||||
productImage: order.imageResource?.url ?? '',
|
||||
unitPrice: Number(order.listUnitPrice ?? 0),
|
||||
quantity: order.quantity ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapOrderCompat<T extends OrderLike & {
|
||||
orderType?: string | null;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
proxyPartnerAccountId?: bigint | number | string | null;
|
||||
}>(order: T) {
|
||||
const payStatus = order.payStatus ?? 'UNPAID';
|
||||
const isProxyOrder = order.orderType === 'PROXY';
|
||||
return {
|
||||
...order,
|
||||
isProxyOrder,
|
||||
proxyPartnerName: order.proxyPartnerName ?? null,
|
||||
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
|
||||
items: [mapOrderItemCompat(order)],
|
||||
payment: {
|
||||
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
|
||||
externalNo: order.payExternalNo ?? null,
|
||||
paidAt: order.paidAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapStoreCompat<T extends { coverResource?: { url?: string } | null }>(store: T) {
|
||||
return {
|
||||
...store,
|
||||
coverUrl: store.coverResource?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapStatusLogCompat(events: CommonEvent[]) {
|
||||
return events.map((e) => ({
|
||||
fromStatus: e.param1,
|
||||
toStatus: e.param2 ?? '',
|
||||
operator: e.param3,
|
||||
remark: e.remark,
|
||||
createdAt: e.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapBenefitLedgerCompat(
|
||||
event: CommonEvent,
|
||||
user?: { userNo?: string | null } | null,
|
||||
coupon?: { couponNo?: string } | null,
|
||||
) {
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.param1,
|
||||
amount: event.amount1 != null ? Number(event.amount1) : 0,
|
||||
balanceAfter: event.amount2 != null ? Number(event.amount2) : 0,
|
||||
remark: event.remark,
|
||||
createdAt: event.createdAt,
|
||||
userId: event.actorId,
|
||||
couponId: event.param2 ? BigInt(event.param2) : null,
|
||||
user: user ? { userNo: user.userNo } : undefined,
|
||||
coupon: coupon ? { couponNo: coupon.couponNo } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||
|
||||
const SALT_LEN = 16;
|
||||
const KEY_LEN = 64;
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const hash = scryptSync(password, salt, KEY_LEN);
|
||||
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const [saltHex, hashHex] = stored.split(':');
|
||||
if (!saltHex || !hashHex) return false;
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const expected = Buffer.from(hashHex, 'hex');
|
||||
const actual = scryptSync(password, salt, expected.length);
|
||||
if (actual.length !== expected.length) return false;
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthUser } from '../guards/jwt-auth.guard';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
|
||||
export function serializeBigInt<T>(value: T): T {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { PartnerPermissionKey } from '@dukang/shared-types';
|
||||
|
||||
export const PARTNER_PERMISSIONS_KEY = 'partner_permissions';
|
||||
|
||||
export const RequirePartnerPermissions = (...permissions: PartnerPermissionKey[]) =>
|
||||
SetMetadata(PARTNER_PERMISSIONS_KEY, permissions);
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type BenefitLedgerType = 'GRANT' | 'REDEEM' | 'REFUND_VOID' | 'ADJUST';
|
||||
|
||||
export function buildBenefitLedgerEvent(data: {
|
||||
userId: bigint;
|
||||
couponId: bigint;
|
||||
type: BenefitLedgerType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
refType: string;
|
||||
refId?: bigint;
|
||||
remark?: string;
|
||||
}): Prisma.CommonEventCreateInput {
|
||||
return {
|
||||
eventType: 'BENEFIT_LEDGER',
|
||||
refType: data.refType,
|
||||
refId: data.refId ?? data.couponId,
|
||||
actorType: 'USER',
|
||||
actorId: data.userId,
|
||||
param1: data.type,
|
||||
param1Desc: 'ledger_type',
|
||||
param2: data.couponId.toString(),
|
||||
param2Desc: 'coupon_id',
|
||||
amount1: data.amount,
|
||||
amount2: data.balanceAfter,
|
||||
remark: data.remark,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrderStatusEvent(data: {
|
||||
orderId: bigint;
|
||||
fromStatus: string;
|
||||
toStatus: string;
|
||||
operator: string;
|
||||
remark?: string;
|
||||
}): Prisma.CommonEventCreateInput {
|
||||
return {
|
||||
eventType: 'ORDER_STATUS',
|
||||
refType: 'ORDER',
|
||||
refId: data.orderId,
|
||||
actorType: 'SYSTEM',
|
||||
param1: data.fromStatus,
|
||||
param1Desc: 'from_status',
|
||||
param2: data.toStatus,
|
||||
param2Desc: 'to_status',
|
||||
param3: data.operator,
|
||||
param3Desc: 'operator',
|
||||
remark: data.remark,
|
||||
};
|
||||
}
|
||||
|
||||
export function benefitLedgerWhere(userId?: bigint, couponId?: bigint): Prisma.CommonEventWhereInput {
|
||||
return {
|
||||
eventType: 'BENEFIT_LEDGER',
|
||||
...(userId ? { actorType: 'USER', actorId: userId } : {}),
|
||||
...(couponId ? { param2: couponId.toString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function orderStatusLogWhere(orderId: bigint): Prisma.CommonEventWhereInput {
|
||||
return {
|
||||
eventType: 'ORDER_STATUS',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHqOperationEvent(data: {
|
||||
hqAccountId: bigint;
|
||||
action: string;
|
||||
refType: string;
|
||||
refId: bigint;
|
||||
status?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
remark?: string;
|
||||
}): Prisma.CommonEventCreateInput {
|
||||
return {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: data.refType,
|
||||
refId: data.refId,
|
||||
actorType: 'HQ',
|
||||
actorId: data.hqAccountId,
|
||||
status: data.status,
|
||||
param1: data.action,
|
||||
param1Desc: 'action',
|
||||
param2: data.refType,
|
||||
param2Desc: 'target_type',
|
||||
param3: data.refId.toString(),
|
||||
param3Desc: 'target_id',
|
||||
remark: data.remark,
|
||||
extraJson: data.detail as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function hqOperationLogWhere(filters?: {
|
||||
hqAccountId?: bigint;
|
||||
action?: string;
|
||||
refType?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}): Prisma.CommonEventWhereInput {
|
||||
return {
|
||||
eventType: 'HQ_OPERATION',
|
||||
...(filters?.hqAccountId ? { actorType: 'HQ' as const, actorId: filters.hqAccountId } : {}),
|
||||
...(filters?.action ? { param1: filters.action } : {}),
|
||||
...(filters?.refType ? { param2: filters.refType } : {}),
|
||||
...(filters?.from || filters?.to
|
||||
? {
|
||||
createdAt: {
|
||||
...(filters.from ? { gte: filters.from } : {}),
|
||||
...(filters.to ? { lte: filters.to } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AlertService } from '../alert/alert.service';
|
||||
|
||||
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
|
||||
switch (exception.code) {
|
||||
case 'P2002':
|
||||
return '数据冲突,请刷新后重试';
|
||||
case 'P2003':
|
||||
return '关联数据不存在,请检查门店/权益配置';
|
||||
case 'P2021':
|
||||
case 'P2022':
|
||||
return '数据库表结构未同步,请在服务器执行 prisma db push';
|
||||
case 'P2025':
|
||||
return '记录不存在或已被删除';
|
||||
default:
|
||||
return exception.message;
|
||||
}
|
||||
}
|
||||
|
||||
@Catch()
|
||||
@Injectable()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly alert: AlertService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest<{ method?: string; url?: string }>();
|
||||
const method = request?.method ?? '?';
|
||||
const path = request?.url ?? '?';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
const message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (res as { message?: string | string[] }).message || exception.message;
|
||||
const msgText = Array.isArray(message) ? message.join(', ') : message;
|
||||
if (status >= 500) {
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'api_error',
|
||||
title: `API ${status}`,
|
||||
detail: `${method} ${path}\n${msgText}`,
|
||||
dedupeKey: `http_${status}|${method}|${path}|${String(msgText).slice(0, 80)}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
}
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: msgText,
|
||||
data: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
console.error(exception);
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'api_error',
|
||||
title: `Prisma ${exception.code}`,
|
||||
detail: `${method} ${path}\n${exception.message}`,
|
||||
dedupeKey: `prisma|${exception.code}|${method}|${path}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
code: 400,
|
||||
message: prismaErrorMessage(exception),
|
||||
data: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof SyntaxError && /BigInt/i.test(exception.message)) {
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
code: 400,
|
||||
message: 'ID 格式无效',
|
||||
data: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(exception);
|
||||
const errMsg = exception instanceof Error ? exception.message : 'Internal server error';
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'api_error',
|
||||
title: '未捕获异常 500',
|
||||
detail: `${method} ${path}\n${errMsg}`,
|
||||
dedupeKey: `uncaught|${method}|${path}|${errMsg.slice(0, 80)}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
code: 500,
|
||||
message: errMsg,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
function normalizeIp(raw?: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
const ip = raw.replace(/^::ffff:/, '').trim();
|
||||
if (!ip || ip === '::1') return null;
|
||||
return ip;
|
||||
}
|
||||
|
||||
/** 从反向代理 / Socket 提取客户端 IP */
|
||||
export function extractClientIp(req: Request): string | null {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
||||
return normalizeIp(forwarded.split(',')[0]);
|
||||
}
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return normalizeIp(forwarded[0]?.split(',')[0]);
|
||||
}
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
if (typeof realIp === 'string' && realIp.length > 0) {
|
||||
return normalizeIp(realIp);
|
||||
}
|
||||
return normalizeIp(req.ip ?? req.socket?.remoteAddress ?? null);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ClientGpsLocation, OrderClientLocationSnapshot } from './geo.types';
|
||||
import type { IpRegion } from './geo.types';
|
||||
import { extractClientIp } from './client-ip.util';
|
||||
import type { Request } from 'express';
|
||||
|
||||
function parseGpsLocation(raw: unknown): ClientGpsLocation | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const input = raw as Record<string, unknown>;
|
||||
const latitude = Number(input.latitude);
|
||||
const longitude = Number(input.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
||||
|
||||
return {
|
||||
province: input.province != null ? String(input.province).trim() || null : null,
|
||||
city: input.city != null ? String(input.city).trim() || null : null,
|
||||
district: input.district != null ? String(input.district).trim() || null : null,
|
||||
latitude,
|
||||
longitude,
|
||||
address: input.address != null ? String(input.address).trim() || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrderClientLocationSnapshot(
|
||||
req: Request,
|
||||
ipGeo: IpRegion,
|
||||
clientLocationRaw?: unknown,
|
||||
): OrderClientLocationSnapshot {
|
||||
const clientIp = extractClientIp(req);
|
||||
const gps = parseGpsLocation(clientLocationRaw);
|
||||
|
||||
return {
|
||||
clientIp,
|
||||
ipProvince: ipGeo.province,
|
||||
ipCity: ipGeo.city,
|
||||
ipDistrict: ipGeo.district,
|
||||
gpsProvince: gps?.province ?? null,
|
||||
gpsCity: gps?.city ?? null,
|
||||
gpsDistrict: gps?.district ?? null,
|
||||
gpsLatitude: gps?.latitude ?? null,
|
||||
gpsLongitude: gps?.longitude ?? null,
|
||||
gpsAddress: gps?.address ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { IpGeoService } from './ip-geo.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [IpGeoService],
|
||||
exports: [IpGeoService],
|
||||
})
|
||||
export class GeoModule {}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type IpRegion = {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
district: string | null;
|
||||
};
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string | null;
|
||||
city?: string | null;
|
||||
district?: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string | null;
|
||||
};
|
||||
|
||||
export type OrderClientLocationSnapshot = {
|
||||
clientIp: string | null;
|
||||
ipProvince: string | null;
|
||||
ipCity: string | null;
|
||||
ipDistrict: string | null;
|
||||
gpsProvince: string | null;
|
||||
gpsCity: string | null;
|
||||
gpsDistrict: string | null;
|
||||
gpsLatitude: number | null;
|
||||
gpsLongitude: number | null;
|
||||
gpsAddress: string | null;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import IP2Region from 'ip2region';
|
||||
import type { IpRegion } from './geo.types';
|
||||
|
||||
function cleanRegionName(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === '0' || trimmed === '内网IP') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IpGeoService implements OnModuleInit {
|
||||
private readonly logger = new Logger(IpGeoService.name);
|
||||
private query: IP2Region | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
try {
|
||||
this.query = new IP2Region();
|
||||
} catch (error) {
|
||||
this.logger.warn(`ip2region 初始化失败: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(ip: string | null): IpRegion {
|
||||
if (!ip || !this.query) {
|
||||
return { province: null, city: null, district: null };
|
||||
}
|
||||
if (ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return { province: null, city: null, district: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.query.search(ip) as {
|
||||
province?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
return {
|
||||
province: cleanRegionName(result?.province),
|
||||
city: cleanRegionName(result?.city),
|
||||
district: cleanRegionName(result?.region),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.debug(`IP 解析失败 ${ip}: ${error instanceof Error ? error.message : error}`);
|
||||
return { province: null, city: null, district: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ok = super.canActivate(context);
|
||||
if (!ok) return false;
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const clientApp = req.headers['x-client-app'] as ClientApp;
|
||||
if (clientApp !== ClientApp.HQ_WEB) {
|
||||
throw new UnauthorizedException('Invalid client app for admin');
|
||||
}
|
||||
if (req.user?.actorType !== 'HQ') {
|
||||
throw new UnauthorizedException('HQ access required');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
HQ_DANGEROUS_PERMISSION_KEYS,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
expandHqPermissionKeys,
|
||||
hasAnySystemSettingsPermission,
|
||||
hqBasePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
export const HQ_PERMISSIONS_KEY = 'hq:permissions';
|
||||
export const RequireHqPermissions = (...keys: string[]) =>
|
||||
SetMetadata(HQ_PERMISSIONS_KEY, keys);
|
||||
/** 任意一项系统设置分组权限即可访问系统设置接口 */
|
||||
export const RequireAnySystemSettings = () =>
|
||||
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
|
||||
|
||||
@Injectable()
|
||||
export class HqPermissionsResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('HQ 账号不可用');
|
||||
}
|
||||
|
||||
const userRows = await this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: actorId },
|
||||
select: { permissionKey: true },
|
||||
});
|
||||
const userKeys = userRows.map((r) => r.permissionKey);
|
||||
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
|
||||
return expandHqPermissionKeys([
|
||||
...hqBasePermissionKeys(),
|
||||
...HQ_DANGEROUS_PERMISSION_KEYS,
|
||||
...userKeys,
|
||||
]);
|
||||
}
|
||||
|
||||
const roleRows = await this.prisma.hqRolePermission.findMany({
|
||||
where: { adminRole: account.adminRole },
|
||||
select: { permissionKey: true },
|
||||
});
|
||||
|
||||
const roleKeys =
|
||||
roleRows.length > 0
|
||||
? roleRows.map((r) => r.permissionKey)
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||
|
||||
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HqPermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly resolver: HqPermissionsResolver,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
throw new ForbiddenException('需要 HQ 权限');
|
||||
}
|
||||
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
|
||||
req.hqPermissionKeys = keys;
|
||||
|
||||
const required =
|
||||
this.reflector.getAllAndOverride<string[]>(HQ_PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? [];
|
||||
|
||||
if (!required.length) return true;
|
||||
if (required.includes('__any_system_settings__')) {
|
||||
if (!hasAnySystemSettingsPermission(keys)) {
|
||||
throw new ForbiddenException('无系统设置权限');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!required.some((k) => keys.includes(k as HqPermissionKey))) {
|
||||
throw new ForbiddenException('权限不足');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||
|
||||
export interface AuthUser {
|
||||
actorType: string;
|
||||
actorId: bigint;
|
||||
clientApp: ClientApp;
|
||||
sub: string;
|
||||
phoneVerified: boolean;
|
||||
/** Selected store after POST /shop/auth/select-store */
|
||||
storeId?: bigint;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(protected readonly jwtService: JwtService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const auth = req.headers.authorization as string | undefined;
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
try {
|
||||
const payload = this.jwtService.verify(auth.slice(7));
|
||||
const clientApp = req.headers['x-client-app'] as ClientApp;
|
||||
if (!clientApp || payload.clientApp !== clientApp) {
|
||||
throw new UnauthorizedException('Invalid client app');
|
||||
}
|
||||
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
|
||||
if (payload.actorType !== expectedActor) {
|
||||
throw new UnauthorizedException('Actor mismatch');
|
||||
}
|
||||
req.user = {
|
||||
actorType: payload.actorType,
|
||||
actorId: BigInt(payload.actorId),
|
||||
clientApp,
|
||||
sub: payload.sub,
|
||||
phoneVerified: !!payload.phoneVerified,
|
||||
...(payload.storeId != null && payload.storeId !== ''
|
||||
? { storeId: BigInt(payload.storeId) }
|
||||
: {}),
|
||||
} satisfies AuthUser;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
/** C 端 H5 / 小程序同属 USER,目录白名单只认 actorId→手机号,允许端间 token 兼容 */
|
||||
const USER_CLIENT_APPS = new Set<ClientApp>([ClientApp.USER_MINI, ClientApp.USER_H5]);
|
||||
|
||||
function clientAppCompatible(headerApp: ClientApp | undefined, payloadApp: unknown): boolean {
|
||||
if (!headerApp || payloadApp == null) return false;
|
||||
if (headerApp === payloadApp) return true;
|
||||
return USER_CLIENT_APPS.has(headerApp) && USER_CLIENT_APPS.has(payloadApp as ClientApp);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OptionalJwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const auth = req.headers.authorization as string | undefined;
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const payload = this.jwtService.verify(auth.slice(7));
|
||||
const clientApp = req.headers['x-client-app'] as ClientApp;
|
||||
if (!clientAppCompatible(clientApp, payload.clientApp)) return true;
|
||||
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
|
||||
if (payload.actorType !== expectedActor) return true;
|
||||
req.user = {
|
||||
actorType: payload.actorType,
|
||||
actorId: BigInt(payload.actorId),
|
||||
clientApp,
|
||||
sub: payload.sub,
|
||||
phoneVerified: !!payload.phoneVerified,
|
||||
...(payload.storeId != null && payload.storeId !== ''
|
||||
? { storeId: BigInt(payload.storeId) }
|
||||
: {}),
|
||||
} satisfies AuthUser;
|
||||
} catch {
|
||||
/* ignore invalid token */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { PartnerPermissionKey } from '@dukang/shared-types';
|
||||
import { PARTNER_PERMISSIONS_KEY } from '../decorators/partner-permission.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class PartnerPermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtAuthGuard: JwtAuthGuard,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
await this.jwtAuthGuard.canActivate(context);
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser;
|
||||
if (user.actorType !== 'PARTNER') {
|
||||
throw new ForbiddenException('仅合伙人可操作');
|
||||
}
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: user.actorId },
|
||||
});
|
||||
if (account.isPrimary === 1) return true;
|
||||
|
||||
const required = this.reflector.getAllAndOverride<PartnerPermissionKey[]>(
|
||||
PARTNER_PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!required?.length) return true;
|
||||
|
||||
const perms = Array.isArray(account.permissions)
|
||||
? (account.permissions as string[])
|
||||
: [];
|
||||
if (required.some((p) => perms.includes(p))) return true;
|
||||
throw new ForbiddenException('当前子账号无此操作权限');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class PartnerPrimaryGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtAuthGuard: JwtAuthGuard,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
this.jwtAuthGuard.canActivate(context);
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser;
|
||||
if (user.actorType !== 'PARTNER') {
|
||||
throw new ForbiddenException('仅合伙人主账号可操作');
|
||||
}
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: user.actorId },
|
||||
});
|
||||
if (account.isPrimary !== 1) {
|
||||
throw new ForbiddenException('仅主账号可操作');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class PhoneVerifiedGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'USER') {
|
||||
throw new ForbiddenException('请先验证手机号');
|
||||
}
|
||||
|
||||
const row = await this.prisma.user.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { phoneVerifiedAt: true, mergedIntoUserId: true, status: true },
|
||||
});
|
||||
if (!row || row.status !== 1 || row.mergedIntoUserId) {
|
||||
throw new ForbiddenException('账号状态异常,请重新进入');
|
||||
}
|
||||
if (!row.phoneVerifiedAt) {
|
||||
throw new ForbiddenException('请先验证手机号');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
/** 门店主账号专用(提现等资金操作) */
|
||||
@Injectable()
|
||||
export class ShopPrimaryGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'STORE') {
|
||||
throw new ForbiddenException('仅门店主账号可操作');
|
||||
}
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { isPrimary: true },
|
||||
});
|
||||
if (!account || account.isPrimary !== 1) {
|
||||
throw new ForbiddenException('仅门店主账号可申请提现');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
/** Shop business APIs require JWT claim storeId (after select-store). */
|
||||
@Injectable()
|
||||
export class ShopStoreGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user?.storeId) {
|
||||
throw new ForbiddenException('请先选择门店');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class StoreMembershipService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async assertStoreMembership(accountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUnique({
|
||||
where: {
|
||||
storeAccountId_storeId: { storeAccountId: accountId, storeId },
|
||||
},
|
||||
});
|
||||
if (!binding) {
|
||||
throw new ForbiddenException('无权访问该门店');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
requireShopStoreId(user: AuthUser): bigint {
|
||||
if (!user.storeId) {
|
||||
throw new ForbiddenException('请先选择门店');
|
||||
}
|
||||
return user.storeId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
throw new ForbiddenException('需要 HQ 权限');
|
||||
}
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE' || account.adminRole !== 'SUPER_ADMIN') {
|
||||
throw new ForbiddenException('需要超级管理员权限');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import { buildHqOperationEvent } from '../event/event.helpers';
|
||||
|
||||
export type LogHqOperationInput = {
|
||||
hqAccountId: bigint;
|
||||
action: string;
|
||||
refType: string;
|
||||
refId: bigint;
|
||||
status?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class HqOperationLogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async log(input: LogHqOperationInput): Promise<void> {
|
||||
await this.prisma.commonEvent.create({
|
||||
data: buildHqOperationEvent(input),
|
||||
});
|
||||
}
|
||||
|
||||
logSafe(input: LogHqOperationInput): void {
|
||||
void this.log(input).catch((err) => {
|
||||
console.error('[HqOperationLog] write failed', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/** HQ 后台写操作审计 action(写入 common_event.param1) */
|
||||
export const HqOperationAction = {
|
||||
CITY_CREATE: 'CITY_CREATE',
|
||||
CITY_UPDATE: 'CITY_UPDATE',
|
||||
CITY_DELETE: 'CITY_DELETE',
|
||||
CITY_PARTNER_BIND: 'CITY_PARTNER_BIND',
|
||||
CITY_PARTNER_UPDATE: 'CITY_PARTNER_UPDATE',
|
||||
CITY_PARTNER_UNBIND: 'CITY_PARTNER_UNBIND',
|
||||
WAREHOUSE_CREATE: 'WAREHOUSE_CREATE',
|
||||
WAREHOUSE_UPDATE: 'WAREHOUSE_UPDATE',
|
||||
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
|
||||
PARTNER_CREATE: 'PARTNER_CREATE',
|
||||
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
||||
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
||||
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
||||
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
|
||||
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
|
||||
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
|
||||
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
|
||||
USER_DELETE: 'USER_DELETE',
|
||||
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
|
||||
ORDER_SHIP: 'ORDER_SHIP',
|
||||
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
|
||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||
ORDER_DELETE: 'ORDER_DELETE',
|
||||
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||
STORE_CREATE: 'STORE_CREATE',
|
||||
STORE_UPDATE: 'STORE_UPDATE',
|
||||
STORE_STATUS: 'STORE_STATUS',
|
||||
STORE_AUDIT: 'STORE_AUDIT',
|
||||
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
||||
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
|
||||
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
|
||||
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
||||
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
||||
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
||||
STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE',
|
||||
STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE',
|
||||
STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE',
|
||||
STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE',
|
||||
PRODUCT_CREATE: 'PRODUCT_CREATE',
|
||||
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
|
||||
PRODUCT_DELETE: 'PRODUCT_DELETE',
|
||||
PRODUCT_TEMPLATE_CREATE: 'PRODUCT_TEMPLATE_CREATE',
|
||||
PRODUCT_TEMPLATE_UPDATE: 'PRODUCT_TEMPLATE_UPDATE',
|
||||
BENEFIT_COUPON_VOID: 'BENEFIT_COUPON_VOID',
|
||||
BENEFIT_COUPON_GRANT: 'BENEFIT_COUPON_GRANT',
|
||||
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
|
||||
TICKET_APPROVE: 'TICKET_APPROVE',
|
||||
TICKET_REJECT: 'TICKET_REJECT',
|
||||
TICKET_CREATE: 'TICKET_CREATE',
|
||||
SUPPORT_TICKET_CREATE: 'SUPPORT_TICKET_CREATE',
|
||||
SUPPORT_TICKET_APPROVE: 'SUPPORT_TICKET_APPROVE',
|
||||
SUPPORT_TICKET_REJECT: 'SUPPORT_TICKET_REJECT',
|
||||
SUPPORT_TICKET_START_TESTING: 'SUPPORT_TICKET_START_TESTING',
|
||||
SUPPORT_TICKET_PASS: 'SUPPORT_TICKET_PASS',
|
||||
INVOICE_CREATE: 'INVOICE_CREATE',
|
||||
INVOICE_ISSUE: 'INVOICE_ISSUE',
|
||||
INVOICE_REJECT: 'INVOICE_REJECT',
|
||||
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
|
||||
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
||||
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
||||
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
|
||||
STORE_WITHDRAW_APPROVE: 'STORE_WITHDRAW_APPROVE',
|
||||
STORE_WITHDRAW_REJECT: 'STORE_WITHDRAW_REJECT',
|
||||
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
||||
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
|
||||
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
|
||||
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
|
||||
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
|
||||
PARTNER_BILL_BATCH_MARK_PAID: 'PARTNER_BILL_BATCH_MARK_PAID',
|
||||
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
|
||||
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
|
||||
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
|
||||
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
|
||||
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
|
||||
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
|
||||
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
||||
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
|
||||
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
||||
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
|
||||
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
|
||||
WECOM_BOT_CREATE: 'WECOM_BOT_CREATE',
|
||||
WECOM_BOT_UPDATE: 'WECOM_BOT_UPDATE',
|
||||
WECOM_BOT_DELETE: 'WECOM_BOT_DELETE',
|
||||
WECOM_BOT_RELOAD: 'WECOM_BOT_RELOAD',
|
||||
WECOM_MESSAGE_PUSH_CREATE: 'WECOM_MESSAGE_PUSH_CREATE',
|
||||
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
|
||||
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
|
||||
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
|
||||
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
|
||||
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
|
||||
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
|
||||
LLM_CONFIG_TEST: 'LLM_CONFIG_TEST',
|
||||
KNOWLEDGE_BASE_CREATE: 'KNOWLEDGE_BASE_CREATE',
|
||||
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
|
||||
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
|
||||
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
|
||||
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
|
||||
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
|
||||
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
|
||||
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
|
||||
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
|
||||
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
|
||||
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
|
||||
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
|
||||
DEV_PLAN_TASK_CREATE: 'DEV_PLAN_TASK_CREATE',
|
||||
DEV_PLAN_TASK_UPDATE: 'DEV_PLAN_TASK_UPDATE',
|
||||
DEV_PLAN_TASK_DELETE: 'DEV_PLAN_TASK_DELETE',
|
||||
DEV_PLAN_TASK_DISPATCH: 'DEV_PLAN_TASK_DISPATCH',
|
||||
DEV_PLAN_VERSION_CREATE: 'DEV_PLAN_VERSION_CREATE',
|
||||
DEV_PLAN_VERSION_UPDATE: 'DEV_PLAN_VERSION_UPDATE',
|
||||
DEV_PLAN_VERSION_DELETE: 'DEV_PLAN_VERSION_DELETE',
|
||||
DEV_PLAN_VERSION_LINK_TASKS: 'DEV_PLAN_VERSION_LINK_TASKS',
|
||||
DEV_PLAN_VERSION_ADD_TASKS: 'DEV_PLAN_VERSION_ADD_TASKS',
|
||||
DEV_PLAN_SETTINGS_UPDATE: 'DEV_PLAN_SETTINGS_UPDATE',
|
||||
DEV_PLAN_DISPATCH_TEST: 'DEV_PLAN_DISPATCH_TEST',
|
||||
SUPPORT_TICKET_REVIEW: 'SUPPORT_TICKET_REVIEW',
|
||||
SUPPORT_TICKET_BATCH_REVIEW: 'SUPPORT_TICKET_BATCH_REVIEW',
|
||||
} as const;
|
||||
|
||||
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
|
||||
|
||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.CITY_CREATE]: '新增开城城市',
|
||||
[HqOperationAction.CITY_UPDATE]: '编辑开城城市',
|
||||
[HqOperationAction.CITY_DELETE]: '删除开城城市',
|
||||
[HqOperationAction.CITY_PARTNER_BIND]: '绑定城市合伙人',
|
||||
[HqOperationAction.CITY_PARTNER_UPDATE]: '编辑城市合伙人绑定',
|
||||
[HqOperationAction.CITY_PARTNER_UNBIND]: '解绑城市合伙人',
|
||||
[HqOperationAction.WAREHOUSE_CREATE]: '新增城市仓库',
|
||||
[HqOperationAction.WAREHOUSE_UPDATE]: '编辑城市仓库',
|
||||
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
|
||||
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
|
||||
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
|
||||
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
|
||||
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
|
||||
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
|
||||
[HqOperationAction.USER_DELETE]: '删除用户',
|
||||
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
|
||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
|
||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||
[HqOperationAction.ORDER_DELETE]: '删除订单',
|
||||
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
||||
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
||||
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
|
||||
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
|
||||
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
||||
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
||||
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
|
||||
[HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类',
|
||||
[HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类',
|
||||
[HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类',
|
||||
[HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类',
|
||||
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
|
||||
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
|
||||
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
|
||||
[HqOperationAction.PRODUCT_TEMPLATE_CREATE]: '新增详情模板',
|
||||
[HqOperationAction.PRODUCT_TEMPLATE_UPDATE]: '编辑详情模板',
|
||||
[HqOperationAction.BENEFIT_COUPON_VOID]: '作废权益券',
|
||||
[HqOperationAction.BENEFIT_COUPON_GRANT]: '手动发放权益',
|
||||
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
|
||||
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
||||
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
||||
[HqOperationAction.TICKET_CREATE]: '创建工单',
|
||||
[HqOperationAction.SUPPORT_TICKET_CREATE]: '创建技术支持工单',
|
||||
[HqOperationAction.SUPPORT_TICKET_APPROVE]: '技术支持评审通过',
|
||||
[HqOperationAction.SUPPORT_TICKET_REJECT]: '技术支持评审驳回',
|
||||
[HqOperationAction.SUPPORT_TICKET_START_TESTING]: '技术支持转入测试',
|
||||
[HqOperationAction.SUPPORT_TICKET_PASS]: '技术支持测试通过',
|
||||
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
|
||||
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
|
||||
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
|
||||
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
|
||||
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
||||
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
||||
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
|
||||
[HqOperationAction.STORE_WITHDRAW_APPROVE]: '门店提现审核通过',
|
||||
[HqOperationAction.STORE_WITHDRAW_REJECT]: '门店提现驳回',
|
||||
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
||||
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
|
||||
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
|
||||
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
|
||||
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
|
||||
[HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID]: '批量合伙人账单结算',
|
||||
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
|
||||
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
|
||||
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
|
||||
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
|
||||
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
|
||||
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
|
||||
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
||||
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
||||
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
||||
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
|
||||
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
|
||||
[HqOperationAction.WECOM_BOT_CREATE]: '创建企微机器人',
|
||||
[HqOperationAction.WECOM_BOT_UPDATE]: '编辑企微机器人',
|
||||
[HqOperationAction.WECOM_BOT_DELETE]: '删除企微机器人',
|
||||
[HqOperationAction.WECOM_BOT_RELOAD]: '重载企微机器人连接',
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_CREATE]: '创建企微消息推送',
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
|
||||
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_TEST]: '测试语言模型配置',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_CREATE]: '创建知识库',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
|
||||
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
|
||||
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
|
||||
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
|
||||
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
|
||||
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
|
||||
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
|
||||
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
|
||||
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
|
||||
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
|
||||
[HqOperationAction.DEV_PLAN_TASK_CREATE]: '创建开发计划任务',
|
||||
[HqOperationAction.DEV_PLAN_TASK_UPDATE]: '更新开发计划任务',
|
||||
[HqOperationAction.DEV_PLAN_TASK_DELETE]: '删除开发计划任务',
|
||||
[HqOperationAction.DEV_PLAN_TASK_DISPATCH]: '派发开发计划任务',
|
||||
[HqOperationAction.DEV_PLAN_VERSION_CREATE]: '创建开发计划版本',
|
||||
[HqOperationAction.DEV_PLAN_VERSION_UPDATE]: '更新开发计划版本',
|
||||
[HqOperationAction.DEV_PLAN_VERSION_DELETE]: '删除开发计划版本',
|
||||
[HqOperationAction.DEV_PLAN_VERSION_LINK_TASKS]: '关联版本任务',
|
||||
[HqOperationAction.DEV_PLAN_VERSION_ADD_TASKS]: '追加关联版本任务',
|
||||
[HqOperationAction.DEV_PLAN_SETTINGS_UPDATE]: '更新开发计划设置',
|
||||
[HqOperationAction.DEV_PLAN_DISPATCH_TEST]: '测试任务派发助手',
|
||||
[HqOperationAction.SUPPORT_TICKET_REVIEW]: '技术支持工单审批',
|
||||
[HqOperationAction.SUPPORT_TICKET_BATCH_REVIEW]: '技术支持批量审批',
|
||||
STORE_PAYOUT: '门店打款确认',
|
||||
};
|
||||
|
||||
export function resolveHqOperationLabel(action: string | null | undefined, refType?: string | null): string {
|
||||
if (action && HQ_OPERATION_ACTION_LABELS[action]) {
|
||||
return HQ_OPERATION_ACTION_LABELS[action];
|
||||
}
|
||||
if (refType && HQ_OPERATION_ACTION_LABELS[refType]) {
|
||||
return HQ_OPERATION_ACTION_LABELS[refType];
|
||||
}
|
||||
return action || refType || 'HQ 操作';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { HqOperationActionCode } from './hq-operation.constants';
|
||||
|
||||
export const HQ_OPERATION_KEY = 'hq_operation';
|
||||
|
||||
export type HqOperationMeta = {
|
||||
action: HqOperationActionCode | string;
|
||||
refType: string;
|
||||
/** 从路由 params 取 refId */
|
||||
refIdParam?: string;
|
||||
/** 从响应体字段取 refId,默认 id */
|
||||
refIdField?: string;
|
||||
/** 批量操作无单一 refId 时用 0 */
|
||||
batch?: boolean;
|
||||
/** 记录请求体到 extraJson */
|
||||
includeBody?: boolean;
|
||||
/** 记录响应体到 extraJson(截断) */
|
||||
includeResponse?: boolean;
|
||||
};
|
||||
|
||||
export const HqOperation = (meta: HqOperationMeta) => SetMetadata(HQ_OPERATION_KEY, meta);
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
import type { RequestWithId } from '../logging/request-id.middleware';
|
||||
import { HQ_OPERATION_KEY, type HqOperationMeta } from './hq-operation.decorator';
|
||||
import { HqOperationLogService } from './hq-operation-log.service';
|
||||
|
||||
function pickRefId(value: unknown): bigint | null {
|
||||
if (value == null || value === '') return null;
|
||||
try {
|
||||
return BigInt(String(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeBody(body: unknown): unknown {
|
||||
if (!body || typeof body !== 'object') return body;
|
||||
const copy = { ...(body as Record<string, unknown>) };
|
||||
for (const key of Object.keys(copy)) {
|
||||
if (/password|secret|token/i.test(key)) {
|
||||
copy[key] = '***';
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function summarizeResponse(data: unknown): unknown {
|
||||
if (data == null) return null;
|
||||
if (typeof data !== 'object') return data;
|
||||
const obj = data as Record<string, unknown>;
|
||||
const summary: Record<string, unknown> = {};
|
||||
for (const key of ['id', 'ok', 'deleted', 'orderNo', 'userNo', 'redeemNo', 'message', 'status']) {
|
||||
if (obj[key] !== undefined) summary[key] = obj[key];
|
||||
}
|
||||
if (Array.isArray(obj.items)) {
|
||||
summary.itemCount = obj.items.length;
|
||||
}
|
||||
if (Array.isArray(obj.orderNos)) {
|
||||
summary.orderNos = obj.orderNos;
|
||||
}
|
||||
return Object.keys(summary).length ? summary : obj;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HqOperationInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly logService: HqOperationLogService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const meta = this.reflector.get<HqOperationMeta | undefined>(
|
||||
HQ_OPERATION_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!meta) return next.handle();
|
||||
|
||||
const req = context.switchToHttp().getRequest<RequestWithId & { user?: AuthUser }>();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const writeLog = (status: 'SUCCESS' | 'FAILED', data?: unknown, errorMessage?: string) => {
|
||||
const refId = meta.batch
|
||||
? 0n
|
||||
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
|
||||
?? pickRefId(
|
||||
meta.refIdField
|
||||
? (data as Record<string, unknown> | null)?.[meta.refIdField]
|
||||
: (data as Record<string, unknown> | null)?.id,
|
||||
)
|
||||
?? 0n;
|
||||
|
||||
const detail: Record<string, unknown> = {
|
||||
method: req.method,
|
||||
path: req.originalUrl ?? req.url,
|
||||
requestId: req.requestId,
|
||||
};
|
||||
if (meta.includeBody && req.body) {
|
||||
detail.requestBody = sanitizeBody(req.body);
|
||||
}
|
||||
if (status === 'SUCCESS' && meta.includeResponse !== false && data != null) {
|
||||
detail.response = summarizeResponse(data);
|
||||
}
|
||||
if (errorMessage) detail.error = errorMessage;
|
||||
|
||||
this.logService.logSafe({
|
||||
hqAccountId: user.actorId,
|
||||
action: meta.action,
|
||||
refType: meta.refType,
|
||||
refId,
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) => writeLog('SUCCESS', data)),
|
||||
catchError((err: { message?: string }) => {
|
||||
writeLog('FAILED', undefined, err?.message ?? '操作失败');
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { HqOperationLogService } from './hq-operation-log.service';
|
||||
import { HqOperationInterceptor } from './hq-operation.interceptor';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
HqOperationLogService,
|
||||
HqOperationInterceptor,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: HqOperationInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [HqOperationLogService],
|
||||
})
|
||||
export class HqOperationModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: data ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
import type { RequestWithId } from './request-id.middleware';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const started = Date.now();
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<RequestWithId & { user?: AuthUser }>();
|
||||
const res = http.getResponse<Response>();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => this.logLine(req, res.statusCode, Date.now() - started),
|
||||
error: (err: { status?: number; message?: string }) => {
|
||||
const status = err?.status ?? res.statusCode ?? 500;
|
||||
this.logLine(req, status, Date.now() - started, err?.message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private logLine(
|
||||
req: RequestWithId & { user?: AuthUser },
|
||||
status: number,
|
||||
latencyMs: number,
|
||||
error?: string,
|
||||
) {
|
||||
const line = {
|
||||
requestId: req.requestId,
|
||||
method: req.method,
|
||||
path: req.originalUrl || req.url,
|
||||
status,
|
||||
latencyMs,
|
||||
clientApp: req.headers['x-client-app'],
|
||||
actorType: req.user?.actorType,
|
||||
actorId: req.user?.actorId != null ? String(req.user.actorId) : undefined,
|
||||
error: error?.slice(0, 200),
|
||||
};
|
||||
if (status >= 500) this.logger.error(JSON.stringify(line));
|
||||
else if (status >= 400) this.logger.warn(JSON.stringify(line));
|
||||
else this.logger.log(JSON.stringify(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RequestIdMiddleware } from './request-id.middleware';
|
||||
import { LoggingInterceptor } from './logging.interceptor';
|
||||
|
||||
@Module({
|
||||
providers: [RequestIdMiddleware, LoggingInterceptor],
|
||||
exports: [RequestIdMiddleware, LoggingInterceptor],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
export type RequestWithId = Request & { requestId?: string };
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: RequestWithId, res: Response, next: NextFunction) {
|
||||
const incoming = req.headers[REQUEST_ID_HEADER];
|
||||
const requestId =
|
||||
typeof incoming === 'string' && incoming.trim()
|
||||
? incoming.trim().slice(0, 64)
|
||||
: randomUUID();
|
||||
req.requestId = requestId;
|
||||
res.setHeader('X-Request-Id', requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { MockSmsCodeItem } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
|
||||
const LIST_LIMIT = 50;
|
||||
|
||||
@Injectable()
|
||||
export class MockSmsCodeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(phone: string, scene: string, code: string) {
|
||||
await this.prisma.mockSmsCode.create({
|
||||
data: { phone, scene, code },
|
||||
});
|
||||
}
|
||||
|
||||
async listRecent(limit = LIST_LIMIT): Promise<MockSmsCodeItem[]> {
|
||||
const rows = await this.prisma.mockSmsCode.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
phone: row.phone,
|
||||
scene: row.scene,
|
||||
code: row.code,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export function parseBigIntParam(value: unknown, label = 'ID'): bigint {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!/^\d+$/.test(text)) {
|
||||
throw new BadRequestException(`${label}格式无效`);
|
||||
}
|
||||
try {
|
||||
return BigInt(text);
|
||||
} catch {
|
||||
throw new BadRequestException(`${label}格式无效`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
useFactory: () => new Redis(process.env.REDIS_URL || 'redis://localhost:6379'),
|
||||
},
|
||||
RedisService,
|
||||
],
|
||||
exports: ['REDIS_CLIENT', RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService {
|
||||
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
|
||||
|
||||
get client() {
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds?: number) {
|
||||
const payload = JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.redis.set(key, payload, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await this.redis.set(key, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await this.redis.get(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
}
|
||||
|
||||
async del(key: string) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key);
|
||||
}
|
||||
|
||||
async incr(key: string, ttlSeconds?: number): Promise<number> {
|
||||
const count = await this.redis.incr(key);
|
||||
if (ttlSeconds && count === 1) {
|
||||
await this.redis.expire(key, ttlSeconds);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { resolve } from 'path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
|
||||
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
|
||||
|
||||
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
|
||||
|
||||
function apiRoot() {
|
||||
return resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
export function resolveEnvFilePath() {
|
||||
const isProduction = (process.env.NODE_ENV ?? 'development') === 'production';
|
||||
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
|
||||
}
|
||||
|
||||
function normalizeConfigValue(key: string, value: string): string {
|
||||
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
|
||||
export async function preloadSystemConfigEnv(): Promise<number> {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const rows = await prisma.systemConfig.findMany();
|
||||
for (const row of rows) {
|
||||
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
|
||||
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
|
||||
}
|
||||
}
|
||||
return rows.length;
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === 'P2021'
|
||||
) {
|
||||
console.warn('[config] system_config 表不存在,跳过 DB 预加载');
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvOverlay(values: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
|
||||
process.env[key] = normalizeConfigValue(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SystemConfigService, MockSmsCodeService],
|
||||
exports: [SystemConfigService, MockSmsCodeService],
|
||||
})
|
||||
export class SystemConfigModule {}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types';
|
||||
|
||||
/** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */
|
||||
export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
||||
{ key: 'feature', label: '功能开关' },
|
||||
{ key: 'sms', label: '短信' },
|
||||
{ key: 'wechat', label: '微信' },
|
||||
{ key: 'wechat_mini', label: '微信小程序配置' },
|
||||
{ key: 'oss', label: '对象存储 OSS' },
|
||||
{ key: 'app', label: '应用链接' },
|
||||
{ key: 'deploy', label: '发布部署' },
|
||||
{ key: 'winery_bank', label: '酒厂银行账户' },
|
||||
{ key: 'finance', label: '财务结算' },
|
||||
];
|
||||
|
||||
const G = {
|
||||
feature: 'feature',
|
||||
sms: 'sms',
|
||||
wechat: 'wechat',
|
||||
wechat_mini: 'wechat_mini',
|
||||
oss: 'oss',
|
||||
app: 'app',
|
||||
deploy: 'deploy',
|
||||
winery_bank: 'winery_bank',
|
||||
finance: 'finance',
|
||||
} as const;
|
||||
|
||||
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
|
||||
export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
{
|
||||
key: 'MOCK_SMS',
|
||||
label: 'Mock 短信',
|
||||
group: G.feature,
|
||||
type: 'boolean',
|
||||
requiresRestart: false,
|
||||
description: '开启后不发真实短信,验证码为随机 6 位数字,并记录在下方列表',
|
||||
},
|
||||
{
|
||||
key: 'MOCK_PAY',
|
||||
label: 'Mock 支付',
|
||||
group: G.feature,
|
||||
type: 'boolean',
|
||||
requiresRestart: false,
|
||||
description: '关闭且已配置微信商户参数时走真实 JSAPI 支付',
|
||||
},
|
||||
{
|
||||
key: 'MOCK_WECHAT',
|
||||
label: 'Mock 微信授权',
|
||||
group: G.feature,
|
||||
type: 'boolean',
|
||||
requiresRestart: false,
|
||||
description: '开启走 Mock OAuth/登录;关闭且已配置 WX_APP_ID/SECRET 时走真实微信',
|
||||
},
|
||||
{ key: 'MOCK_DELIVERY_AUTO', label: 'Mock 配送自动完成', group: G.feature, type: 'boolean', requiresRestart: false },
|
||||
{ key: 'AUTO_APPROVE_STORE', label: '门店自动审核通过', group: G.feature, type: 'boolean', requiresRestart: false },
|
||||
{
|
||||
key: 'WECOM_AIBOT_ENABLED',
|
||||
label: '启用企微机器人长连接',
|
||||
group: G.feature,
|
||||
type: 'boolean',
|
||||
requiresRestart: false,
|
||||
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
|
||||
},
|
||||
|
||||
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
|
||||
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
|
||||
{ key: 'ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM', label: '核销确认模板', group: G.sms, type: 'string', requiresRestart: false },
|
||||
{ key: 'ALIYUN_SMS_TEMPLATE_PROXY_ORDER', label: '代下单确认模板', group: G.sms, type: 'string', requiresRestart: false },
|
||||
{ key: 'ALIYUN_SMS_ACCESS_KEY_ID', label: '短信 AccessKey ID', group: G.sms, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'ALIYUN_SMS_ACCESS_KEY_SECRET', label: '短信 AccessKey Secret', group: G.sms, type: 'password', secret: true, requiresRestart: true },
|
||||
|
||||
{ key: 'WX_APP_ID', label: '服务号 AppID', group: G.wechat, type: 'string', requiresRestart: true },
|
||||
{ key: 'WX_APP_SECRET', label: '服务号 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'WX_MINI_APP_ID', label: '小程序 AppID', group: G.wechat, type: 'string', requiresRestart: true },
|
||||
{ key: 'WX_MINI_APP_SECRET', label: '小程序 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'WX_MCH_ID', label: '微信商户号', group: G.wechat, type: 'string', requiresRestart: true },
|
||||
{ key: 'WX_MCH_SERIAL_NO', label: '商户证书序列号', group: G.wechat, type: 'string', requiresRestart: true },
|
||||
{ key: 'WX_MCH_PRIVATE_KEY', label: '商户私钥 PEM', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
|
||||
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
|
||||
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
||||
{ key: 'WX_REFUND_NOTIFY_URL', label: '退款回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
||||
{
|
||||
key: 'WX_MINI_MSG_TOKEN',
|
||||
label: '小程序消息推送 Token',
|
||||
group: G.wechat,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: true,
|
||||
description:
|
||||
'小程序后台「开发-开发管理-消息推送」Token;回调 URL=/api/v1/callbacks/wechat/message',
|
||||
},
|
||||
{
|
||||
key: 'WX_MINI_MSG_AES_KEY',
|
||||
label: '小程序消息推送 EncodingAESKey',
|
||||
group: G.wechat,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: true,
|
||||
description: '消息推送安全模式 EncodingAESKey(43 位);明文模式可留空',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'MINI_HOME_BANNERS',
|
||||
label: '首页轮播图',
|
||||
group: G.wechat_mini,
|
||||
type: 'imageList',
|
||||
requiresRestart: false,
|
||||
description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张;上传后需点击右上角「保存」',
|
||||
},
|
||||
{
|
||||
key: 'MINI_HOME_FOOTER_URL',
|
||||
label: '首页底部图',
|
||||
group: G.wechat_mini,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」',
|
||||
},
|
||||
|
||||
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'OSS_BUCKET', label: 'OSS Bucket', group: G.oss, type: 'string', requiresRestart: true },
|
||||
{ key: 'OSS_REGION', label: 'OSS Region', group: G.oss, type: 'string', requiresRestart: true, placeholder: 'oss-cn-hangzhou' },
|
||||
{ key: 'OSS_ENDPOINT', label: 'OSS Endpoint', group: G.oss, type: 'string', requiresRestart: true, description: '可选,内网 endpoint' },
|
||||
{ key: 'OSS_AUTHORIZATION_V4', label: 'OSS V4 签名', group: G.oss, type: 'boolean', requiresRestart: true },
|
||||
{ key: 'OSS_CDN_BASE', label: 'OSS 公网域名', group: G.oss, type: 'string', requiresRestart: false },
|
||||
{ key: 'OSS_UPLOAD_PREFIX', label: '上传前缀', group: G.oss, type: 'string', requiresRestart: false, placeholder: 'uploads' },
|
||||
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
|
||||
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
|
||||
|
||||
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
|
||||
{
|
||||
key: 'TENCENT_LBS_KEY',
|
||||
label: '腾讯位置服务 Key',
|
||||
group: G.app,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: false,
|
||||
description: '须开启 WebServiceAPI;服务端地理编码/地点搜索与前端选点组件共用',
|
||||
},
|
||||
{
|
||||
key: 'TENCENT_LBS_SECRET_KEY',
|
||||
label: '腾讯位置服务 SecretKey(SK)',
|
||||
group: G.app,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: false,
|
||||
description:
|
||||
'控制台开启 WebServiceAPI「签名校验」后生成;仅服务端计算 sig,勿泄露。配置后接口请求自动附带签名',
|
||||
},
|
||||
|
||||
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
|
||||
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
||||
{
|
||||
key: 'SENTRY_DSN',
|
||||
label: 'Sentry DSN',
|
||||
group: G.deploy,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: true,
|
||||
description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'WINERY_BANK_ACCOUNT_NAME',
|
||||
label: '户名',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '酒厂收款账户户名,打款时对照',
|
||||
},
|
||||
{
|
||||
key: 'WINERY_BANK_NAME',
|
||||
label: '开户银行',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'WINERY_BANK_BRANCH',
|
||||
label: '开户支行',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '可选',
|
||||
},
|
||||
{
|
||||
key: 'WINERY_BANK_ACCOUNT_NO',
|
||||
label: '银行账号',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'STORE_WITHDRAW_DAILY_LIMIT',
|
||||
label: '门店未出账提现单日上限(元)',
|
||||
group: G.finance,
|
||||
type: 'number',
|
||||
requiresRestart: false,
|
||||
description: 'FIN-002:单店单日提现上限,默认 5000',
|
||||
placeholder: '5000',
|
||||
},
|
||||
];
|
||||
|
||||
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
|
||||
export const SYSTEM_CONFIG_RETIRED_KEYS = [
|
||||
'NODE_ENV',
|
||||
'DATABASE_URL',
|
||||
'REDIS_URL',
|
||||
'PORT',
|
||||
'TRUST_PROXY',
|
||||
'JWT_SECRET',
|
||||
'JWT_EXPIRES_IN',
|
||||
'MOCK_SMS_CODE',
|
||||
'WX_AUTHORIZE',
|
||||
'WECHAT_AUTH_ENABLED',
|
||||
'WECHAT_PAY_ENABLED',
|
||||
'OSS_ENABLED',
|
||||
'WECOM_AIBOT_BOT_ID',
|
||||
'WECOM_AIBOT_SECRET',
|
||||
'WECOM_AIBOT_WELCOME',
|
||||
'WECOM_BOT_CS_ENABLED',
|
||||
'WECOM_BOT_CS_BOT_ID',
|
||||
'WECOM_BOT_CS_SECRET',
|
||||
'WECOM_BOT_CS_WELCOME',
|
||||
'WECOM_BOT_CS_PERMISSIONS',
|
||||
'WECOM_BOT_TECH_ENABLED',
|
||||
'WECOM_BOT_TECH_BOT_ID',
|
||||
'WECOM_BOT_TECH_SECRET',
|
||||
'WECOM_BOT_TECH_WELCOME',
|
||||
'WECOM_BOT_TECH_PERMISSIONS',
|
||||
'WECOM_BOT_TEAM_ENABLED',
|
||||
'WECOM_BOT_TEAM_BOT_ID',
|
||||
'WECOM_BOT_TEAM_SECRET',
|
||||
'WECOM_BOT_TEAM_WELCOME',
|
||||
'WECOM_BOT_TEAM_PERMISSIONS',
|
||||
] as const;
|
||||
|
||||
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
|
||||
|
||||
export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined {
|
||||
return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { writeFileSync } from 'fs';
|
||||
import type {
|
||||
SystemConfigFormResponse,
|
||||
SystemConfigSyncResult,
|
||||
SystemConfigUpdateRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import {
|
||||
SYSTEM_CONFIG_FIELDS,
|
||||
SYSTEM_CONFIG_GROUPS,
|
||||
SYSTEM_CONFIG_KEY_SET,
|
||||
SYSTEM_CONFIG_RETIRED_KEYS,
|
||||
getSystemConfigField,
|
||||
} from './system-config.registry';
|
||||
import { applyEnvOverlay, resolveEnvFilePath } from './system-config.env';
|
||||
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
|
||||
|
||||
const SECRET_PLACEHOLDER = '********';
|
||||
|
||||
function isMissingSystemConfigTable(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === 'P2021'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigService implements OnModuleInit {
|
||||
private lastUpdatedAt: Date | null = null;
|
||||
private tableReady = true;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mockSmsCodes: MockSmsCodeService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.purgeRetiredKeys();
|
||||
await this.seedMissingFromProcessEnv();
|
||||
const rows = await this.prisma.systemConfig.findMany();
|
||||
applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value])));
|
||||
this.lastUpdatedAt = rows.reduce<Date | null>(
|
||||
(max, r) => (!max || r.updatedAt > max ? r.updatedAt : max),
|
||||
null,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMissingSystemConfigTable(error)) throw error;
|
||||
this.tableReady = false;
|
||||
console.warn('[config] system_config 表不存在,请执行 npx prisma db push');
|
||||
}
|
||||
}
|
||||
|
||||
getMergedEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
getAppConfig() {
|
||||
return loadAppConfig(this.getMergedEnv());
|
||||
}
|
||||
|
||||
async getForm(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
|
||||
if (!this.tableReady) {
|
||||
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
|
||||
}
|
||||
const groups =
|
||||
allowedGroups == null
|
||||
? SYSTEM_CONFIG_GROUPS
|
||||
: SYSTEM_CONFIG_GROUPS.filter((g) => allowedGroups.includes(g.key));
|
||||
const allowedGroupSet = new Set(groups.map((g) => g.key));
|
||||
const fields = SYSTEM_CONFIG_FIELDS.filter((f) => allowedGroupSet.has(f.group));
|
||||
|
||||
const rows = await this.prisma.systemConfig.findMany();
|
||||
const dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
|
||||
const values: Record<string, string> = {};
|
||||
const configuredSecrets: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const fromDb = dbMap.get(field.key);
|
||||
const fromEnv = process.env[field.key];
|
||||
const raw = fromDb ?? fromEnv ?? '';
|
||||
if (field.secret) {
|
||||
if (raw) configuredSecrets.push(field.key);
|
||||
values[field.key] = '';
|
||||
} else {
|
||||
values[field.key] = raw;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
fields,
|
||||
values,
|
||||
configuredSecrets,
|
||||
envFilePath: resolveEnvFilePath(),
|
||||
updatedAt: this.lastUpdatedAt?.toISOString() ?? null,
|
||||
mockSmsCodes: await this.mockSmsCodes.listRecent(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: SystemConfigUpdateRequest,
|
||||
allowedGroups?: string[] | null,
|
||||
): Promise<{
|
||||
updatedKeys: string[];
|
||||
requiresRestartKeys: string[];
|
||||
}> {
|
||||
const updatedKeys: string[] = [];
|
||||
const requiresRestartKeys: string[] = [];
|
||||
const overlay: Record<string, string> = {};
|
||||
const allowedGroupSet =
|
||||
allowedGroups == null ? null : new Set(allowedGroups);
|
||||
|
||||
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
|
||||
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
|
||||
const meta = getSystemConfigField(key);
|
||||
if (!meta) continue;
|
||||
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) continue;
|
||||
|
||||
let value = String(rawValue ?? '').trim();
|
||||
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
|
||||
continue;
|
||||
}
|
||||
value = this.normalizeByMeta(meta, value);
|
||||
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, value },
|
||||
update: { value },
|
||||
});
|
||||
overlay[key] = value;
|
||||
updatedKeys.push(key);
|
||||
if (meta.requiresRestart) requiresRestartKeys.push(key);
|
||||
}
|
||||
|
||||
if (updatedKeys.length) {
|
||||
applyEnvOverlay(overlay);
|
||||
const latest = await this.prisma.systemConfig.findFirst({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
this.lastUpdatedAt = latest?.updatedAt ?? new Date();
|
||||
}
|
||||
|
||||
return { updatedKeys, requiresRestartKeys: [...new Set(requiresRestartKeys)] };
|
||||
}
|
||||
|
||||
async syncToEnvFile(): Promise<SystemConfigSyncResult> {
|
||||
const rows = await this.prisma.systemConfig.findMany();
|
||||
const path = resolveEnvFilePath();
|
||||
const lines: string[] = [
|
||||
'# 由 HQ 系统设置同步生成,请勿手工删改键名',
|
||||
`# synced_at=${new Date().toISOString()}`,
|
||||
'',
|
||||
];
|
||||
|
||||
let currentGroup = '';
|
||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
||||
if (field.group !== currentGroup) {
|
||||
currentGroup = field.group;
|
||||
const groupLabel = SYSTEM_CONFIG_GROUPS.find((g) => g.key === currentGroup)?.label ?? currentGroup;
|
||||
lines.push(`# --- ${groupLabel} ---`);
|
||||
}
|
||||
const row = rows.find((r) => r.configKey === field.key);
|
||||
const value = row?.value ?? process.env[field.key] ?? '';
|
||||
lines.push(formatEnvLine(field.key, value));
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
writeFileSync(path, lines.join('\n'), 'utf8');
|
||||
|
||||
const requiresRestartKeys = SYSTEM_CONFIG_FIELDS.filter((f) => f.requiresRestart).map((f) => f.key);
|
||||
return {
|
||||
envFilePath: path,
|
||||
writtenKeys: SYSTEM_CONFIG_FIELDS.length,
|
||||
requiresRestartKeys,
|
||||
message: `已写入 ${path},共 ${SYSTEM_CONFIG_FIELDS.length} 项。修改「需重启」类配置后请重启 API 进程。`,
|
||||
};
|
||||
}
|
||||
|
||||
async importFromProcessEnv(): Promise<{ imported: number }> {
|
||||
let imported = 0;
|
||||
const overlay: Record<string, string> = {};
|
||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
||||
const envVal = process.env[field.key];
|
||||
if (envVal === undefined || envVal === '') continue;
|
||||
const normalized = this.normalizeByMeta(field, envVal);
|
||||
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
|
||||
if (existing?.value?.trim()) continue;
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: field.key },
|
||||
create: { configKey: field.key, value: normalized },
|
||||
update: { value: normalized },
|
||||
});
|
||||
overlay[field.key] = normalized;
|
||||
imported += 1;
|
||||
}
|
||||
if (imported) applyEnvOverlay(overlay);
|
||||
await this.onModuleInit();
|
||||
return { imported };
|
||||
}
|
||||
|
||||
private async purgeRetiredKeys() {
|
||||
await this.prisma.systemConfig.deleteMany({
|
||||
where: {
|
||||
configKey: { in: [...SYSTEM_CONFIG_RETIRED_KEYS] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async seedMissingFromProcessEnv() {
|
||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
||||
const envVal = process.env[field.key];
|
||||
if (envVal === undefined || envVal === '') continue;
|
||||
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
|
||||
if (existing?.value?.trim()) continue;
|
||||
const value = this.normalizeByMeta(field, envVal);
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: field.key },
|
||||
create: { configKey: field.key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
|
||||
if (meta.type === 'boolean') {
|
||||
return raw === 'true' || raw === '1' ? 'true' : 'false';
|
||||
}
|
||||
if (meta.type === 'imageList') {
|
||||
return serializeMiniHomeBanners(parseMiniHomeBanners(raw));
|
||||
}
|
||||
if (meta.type === 'image') {
|
||||
return raw.trim();
|
||||
}
|
||||
if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') {
|
||||
let value = raw.trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1).trim();
|
||||
}
|
||||
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvLine(key: string, value: string): string {
|
||||
const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r');
|
||||
if (!needsQuote) return `${key}=${value}`;
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r\n/g, '\\n')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/"/g, '\\"');
|
||||
return `${key}="${escaped}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user