v3.5.3版本更新1
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-20 18:54:15 +08:00
parent ee493823bf
commit fc2e5b65de
65 changed files with 2297 additions and 875 deletions
@@ -1,4 +1,5 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator(
@@ -7,8 +8,29 @@ export const CurrentUser = createParamDecorator(
},
);
export function serializeBigInt<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
);
function isPrismaDecimal(value: unknown): value is Prisma.Decimal {
return Prisma.Decimal.isDecimal(value);
}
function convertForJson(value: unknown): unknown {
if (typeof value === 'bigint') return value.toString();
if (isPrismaDecimal(value)) {
const n = Number(value.toString());
return Number.isFinite(n) ? n : 0;
}
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(convertForJson);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = convertForJson(v);
}
return out;
}
return value;
}
/** JSON 安全序列化:bigint → stringPrisma.Decimal → number(避免金额变成 {} 被前端显示成 0) */
export function serializeBigInt<T>(value: T): T {
return JSON.parse(JSON.stringify(convertForJson(value))) as T;
}
@@ -245,6 +245,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_HINT',
label: '分享引导文案',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_HINT,
@@ -254,6 +255,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_DEFAULT_TITLE',
label: '默认分享标题',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_TITLE,
@@ -263,6 +265,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_DEFAULT_DESC',
label: '默认分享描述',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_DESC,
@@ -272,150 +275,170 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
key: 'SHARE_DEFAULT_IMAGE_URL',
label: '默认分享图',
group: G.wechat_mini_share,
subgroup: 'global',
type: 'image',
requiresRestart: false,
description: '各场景未配置图且无业务图时使用;建议接近 5:4',
},
{
key: 'SHARE_HOME_TITLE',
label: '首页 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'home',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_HOME_DESC',
label: '首页 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'home',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_HOME_IMAGE_URL',
label: '首页 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'home',
type: 'image',
requiresRestart: false,
description: '留空则优先用首页首张轮播图,再回退默认分享图',
},
{
key: 'SHARE_STORES_TITLE',
label: '门店列表 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'stores',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_STORES_TITLE,
},
{
key: 'SHARE_STORES_DESC',
label: '门店列表 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'stores',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_STORES_IMAGE_URL',
label: '门店列表 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'stores',
type: 'image',
requiresRestart: false,
},
{
key: 'SHARE_STORE_DETAIL_TITLE',
label: '门店详情 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'storeDetail',
type: 'string',
requiresRestart: false,
description: '留空则用门店名称',
description: '小程序好友/朋友圈分享固定用门店名称;本项仅作其它端回退参考,可留空',
},
{
key: 'SHARE_STORE_DETAIL_DESC',
label: '门店详情 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'storeDetail',
type: 'string',
requiresRestart: false,
description: '留空则用门店简介/地址',
},
{
key: 'SHARE_STORE_DETAIL_IMAGE_URL',
label: '门店详情 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'storeDetail',
type: 'image',
requiresRestart: false,
description: '留空则用门头图/环境图',
description: '小程序分享固定用门头图/环境图;本项可留空',
},
{
key: 'SHARE_BENEFIT_TITLE',
label: '权益页 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'benefit',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_BENEFIT_TITLE,
},
{
key: 'SHARE_BENEFIT_DESC',
label: '权益页 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'benefit',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_BENEFIT_IMAGE_URL',
label: '权益页 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'benefit',
type: 'image',
requiresRestart: false,
},
{
key: 'SHARE_MINE_TITLE',
label: '我的 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'mine',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_MINE_TITLE,
},
{
key: 'SHARE_MINE_DESC',
label: '我的 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'mine',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_MINE_IMAGE_URL',
label: '我的 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'mine',
type: 'image',
requiresRestart: false,
},
{
key: 'SHARE_PRODUCT_DETAIL_TITLE',
label: '商品详情 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'productDetail',
type: 'string',
requiresRestart: false,
description: '留空则用商品名称',
description: '小程序好友/朋友圈分享固定用商品名称;本项可留空',
},
{
key: 'SHARE_PRODUCT_DETAIL_DESC',
label: '商品详情 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'productDetail',
type: 'string',
requiresRestart: false,
description: '留空则用商品副标题',
},
{
key: 'SHARE_PRODUCT_DETAIL_IMAGE_URL',
label: '商品详情 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'productDetail',
type: 'image',
requiresRestart: false,
description: '留空则用商品主图',
description: '小程序分享固定用商品主图;本项可留空',
},
{
key: 'SHARE_ORDER_DETAIL_TITLE',
label: '订单详情 · 标题',
label: '标题',
group: G.wechat_mini_share,
subgroup: 'orderDetail',
type: 'string',
requiresRestart: false,
placeholder: DEFAULT_SHARE_ORDER_TITLE,
@@ -423,15 +446,17 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
},
{
key: 'SHARE_ORDER_DETAIL_DESC',
label: '订单详情 · 描述',
label: '描述',
group: G.wechat_mini_share,
subgroup: 'orderDetail',
type: 'string',
requiresRestart: false,
},
{
key: 'SHARE_ORDER_DETAIL_IMAGE_URL',
label: '订单详情 · 分享图',
label: '分享图',
group: G.wechat_mini_share,
subgroup: 'orderDetail',
type: 'image',
requiresRestart: false,
},
@@ -3,6 +3,9 @@ import {
WECOM_PUSH_CONDITIONS,
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
WECOM_STORE_AUDIT_PUSH_NAME,
maskWecomWebhookUrl,
parseWecomPushConditions,
type WecomMessagePushDto,
@@ -40,62 +43,102 @@ export class WecomMessagePushService implements OnModuleInit {
}
}
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送v3.4.11 */
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送;并按名称 upsert「门店审核通知群」(v3.5.3 */
async ensureDefaults(): Promise<void> {
const count = await this.prisma.wecomMessagePush.count();
if (count > 0) return;
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
await this.prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: alertEnabled,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
this.logger.log('seeded wecom message push: 运营告警');
}
let devWebhook: string | null = null;
let devUserId: string | null = null;
let devEnabled = false;
try {
const rows = await this.prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row) {
devWebhook = row.task_dispatch_webhook_url;
devUserId = row.task_dispatch_wecom_user_id;
devEnabled = !!row.task_dispatch_enabled;
if (count === 0) {
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
await this.prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: alertEnabled,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
this.logger.log('seeded wecom message push: 运营告警');
}
let devWebhook: string | null = null;
let devUserId: string | null = null;
let devEnabled = false;
try {
const rows = await this.prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row) {
devWebhook = row.task_dispatch_webhook_url;
devUserId = row.task_dispatch_wecom_user_id;
devEnabled = !!row.task_dispatch_enabled;
}
} catch {
// 列已迁移删除,跳过
}
if (devWebhook?.trim()) {
await this.prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: devWebhook.trim(),
enabled: devEnabled,
mentionWecomUserId: devUserId?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
},
});
this.logger.log('seeded wecom message push: 开发任务派发');
}
} catch {
// 列已迁移删除,跳过
}
if (devWebhook?.trim()) {
await this.ensureStoreAuditPush();
}
/** 按名称 upsert「门店审核通知群」:已有行保留 webhook;无行则 env 或占位 URL */
private async ensureStoreAuditPush(): Promise<void> {
const existing = await this.prisma.wecomMessagePush.findFirst({
where: { name: WECOM_STORE_AUDIT_PUSH_NAME },
});
if (existing) return;
const conditionsJson = JSON.stringify(WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS);
const envUrl = (process.env.WECOM_STORE_AUDIT_WEBHOOK_URL || '').trim();
if (envUrl) {
await this.prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: devWebhook.trim(),
enabled: devEnabled,
mentionWecomUserId: devUserId?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
name: WECOM_STORE_AUDIT_PUSH_NAME,
webhookUrl: envUrl,
enabled: true,
pushConditions: conditionsJson,
sortOrder: 20,
},
});
this.logger.log('seeded wecom message push: 开发任务派发');
this.logger.log(`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (from env)`);
return;
}
await this.prisma.wecomMessagePush.create({
data: {
name: WECOM_STORE_AUDIT_PUSH_NAME,
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
enabled: false,
pushConditions: conditionsJson,
sortOrder: 20,
},
});
this.logger.log(
`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (placeholder, disabled)`,
);
}
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
@@ -58,7 +58,16 @@ export class AdminRedeemService {
}),
this.prisma.redeemRecord.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
return serializeBigInt({
items: items.map((row) => ({
...row,
amount: Number(row.amount),
settleAmount: Number(row.settleAmount),
})),
total,
page,
pageSize,
});
}
async detailRecord(id: bigint) {
@@ -9,6 +9,7 @@ import {
generateRedeemNo,
generateRedeemPendingNo,
REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate,
validateRedeemAmount,
allocateBenefitCoupons,
} from '@dukang/domain';
@@ -176,8 +177,15 @@ export class RedeemService {
normalizedAllocations: Array<{ couponId: string; amount: number }>,
analyticsExtra?: { channel: 'token' | 'phone'; sessionId?: string; tokenSuffix?: string },
) {
const settlementRate = Number(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const amountNum = Number(amount);
if (!Number.isFinite(amountNum) || amountNum <= 0) {
throw new BadRequestException('核销金额必须大于 0');
}
const settlementRate = resolveSettlementRate(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amountNum, settlementRate);
if (!Number.isFinite(settleAmount) || settleAmount < 0) {
throw new BadRequestException('结算金额计算异常');
}
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
@@ -200,7 +208,7 @@ export class RedeemService {
userId,
couponId: BigInt(normalizedAllocations[0].couponId),
storeId: account.storeId,
amount,
amount: amountNum,
settleAmount,
channel: redeemChannel,
isTest,
@@ -214,16 +222,14 @@ export class RedeemService {
},
});
if (!isTest) {
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amount,
settleAmount,
settlementRate,
tx,
);
}
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amountNum,
settleAmount,
settlementRate,
tx,
);
return redeemRecord;
});
@@ -240,7 +246,7 @@ export class RedeemService {
const redeemExtra = {
redeemRecordId: record.id.toString(),
storeId: account.storeId.toString(),
amount,
amount: amountNum,
channel: analyticsExtra?.channel ?? 'token',
...(analyticsExtra?.sessionId ? { sessionId: analyticsExtra.sessionId } : {}),
...(analyticsExtra?.tokenSuffix ? { tokenSuffix: analyticsExtra.tokenSuffix } : {}),
@@ -252,7 +258,7 @@ export class RedeemService {
refId: record.id,
extraJson: {
redeemNo: record.redeemNo,
amount,
amount: amountNum,
userId: userId.toString(),
channel: analyticsExtra?.channel ?? 'token',
},
@@ -270,7 +276,11 @@ export class RedeemService {
extraJson: redeemExtra,
});
return record;
return {
...record,
amount: amountNum,
settleAmount,
};
}
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
@@ -484,6 +494,12 @@ export class RedeemService {
}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
const amount = Number(body.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('核销金额必须大于 0');
}
body = { ...body, amount };
let allocations: Array<{ couponId: string; amount: number }>;
if (body.couponId) {
@@ -993,6 +1009,9 @@ export class RedeemService {
await this.validateAllocations(normalizedAllocations);
const amount = Number(pending.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('待处理单核销金额无效');
}
const record = await this.executeRedeem(
account,
pending.userId,
@@ -1101,7 +1120,26 @@ export class RedeemService {
}),
this.prisma.redeemRecord.count({ where: { storeId } }),
]);
return { list: serializeBigInt(list), total, page, pageSize };
return {
list: serializeBigInt(
list.map((r) => ({
...r,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
payout: r.payout
? {
...r.payout,
redeemAmount: Number(r.payout.redeemAmount),
payoutAmount: Number(r.payout.payoutAmount),
settlementRate: Number(r.payout.settlementRate),
}
: r.payout,
})),
),
total,
page,
pageSize,
};
}
async getShopRedeemStats(
@@ -1174,7 +1212,11 @@ export class RedeemService {
todayAmount,
todayScanCount,
todayPhoneCount,
recentRecords: recent,
recentRecords: recent.map((r) => ({
...r,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
})),
});
}
@@ -8,7 +8,9 @@ import {
} from '@dukang/shared-types';
import {
calcLogisticsFeeByBottles,
calcRedeemSettleAmount,
pickPayoutsForWithdrawAmount,
resolveSettlementRate,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
type LogisticsPricingRule,
@@ -96,6 +98,12 @@ export class SettlementService {
settlementRate: number,
tx?: Prisma.TransactionClient,
) {
const redeemAmountNum = Number(redeemAmount);
const payoutAmountNum = Number(payoutAmount);
const settlementRateNum = resolveSettlementRate(settlementRate);
if (!(redeemAmountNum > 0) || !(payoutAmountNum > 0)) {
throw new BadRequestException('结算金额无效,无法入账');
}
const expectedPayAt = new Date();
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
const client = tx ?? this.prisma;
@@ -103,9 +111,9 @@ export class SettlementService {
data: {
redeemRecordId,
storeId,
redeemAmount,
payoutAmount,
settlementRate,
redeemAmount: redeemAmountNum,
payoutAmount: payoutAmountNum,
settlementRate: settlementRateNum,
status: 'PENDING',
expectedPayAt,
},
@@ -152,17 +160,65 @@ export class SettlementService {
}
private async listAvailableUnbilledPayouts(storeId: bigint) {
await this.backfillMissingStorePayouts(storeId);
return this.prisma.storePayout.findMany({
where: {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
withdrawItem: { is: null },
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
/**
* 补写历史核销缺失的 store_payout(含测试流水)。
* 线上曾出现核销成功但未入账、可提余额为 0 的情况。
*/
async backfillMissingStorePayouts(storeId: bigint) {
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { settlementRate: true },
});
if (!store) return 0;
const orphans = await this.prisma.redeemRecord.findMany({
where: {
storeId,
payout: { is: null },
},
select: { id: true, amount: true, settleAmount: true },
take: 200,
});
if (!orphans.length) return 0;
const settlementRate = resolveSettlementRate(store.settlementRate);
let created = 0;
for (const row of orphans) {
const redeemAmount = Number(row.amount);
let payoutAmount = Number(row.settleAmount);
if (!(redeemAmount > 0)) continue;
if (!(payoutAmount > 0)) {
payoutAmount = calcRedeemSettleAmount(redeemAmount, settlementRate);
}
if (!(payoutAmount > 0)) continue;
try {
await this.createStorePayout(
row.id,
storeId,
redeemAmount,
payoutAmount,
settlementRate,
);
created += 1;
} catch {
// 并发补写时可能已存在 payout
}
}
return created;
}
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
const start = startOfDay(now);
const end = new Date(start);
@@ -291,7 +347,7 @@ export class SettlementService {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
withdrawItem: { is: null },
},
select: { id: true, payoutAmount: true },
});
@@ -782,7 +838,7 @@ export class SettlementService {
storeBillId: null,
createdAt: { gte: start, lt: end },
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
withdrawItem: null,
withdrawItem: { is: null },
status: 'PENDING',
},
include: { store: { select: { id: true, settlementRate: true } } },
@@ -1322,12 +1378,9 @@ export class SettlementService {
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const orderCommission = primary.isTest
? 0
: orders.reduce((sum, o) => {
const orderCommission = orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
@@ -1341,14 +1394,12 @@ export class SettlementService {
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems =
primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
const redeems = !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const redeemCommission = redeems.reduce(
@@ -1593,7 +1644,6 @@ export class SettlementService {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
isTest: false,
},
orderBy: { completedAt: 'asc' },
});
@@ -1944,7 +1994,6 @@ export class SettlementService {
quantity: true,
deliveryType: true,
payStatus: true,
isTest: true,
},
},
},
@@ -1953,7 +2002,6 @@ export class SettlementService {
const eligible = deliveries.filter(
(d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
@@ -22,6 +22,14 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
const MIN_ENV_PHOTOS = 3;
const MAX_ENV_PHOTOS = 20;
/** Store 表标量字段(不含媒体) */
const STORE_SCALAR_CHANGE_FIELDS = STORE_INFO_CHANGEABLE_FIELDS.filter(
(f) => f !== 'coverUrl' && f !== 'envPhotoUrls',
) as ChangeableField[];
function normalizeOptionalTextField(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
@@ -45,6 +53,27 @@ function coerceNumberOrNull(value: unknown): number | null {
return n;
}
function normalizeEnvPhotoUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) {
throw new BadRequestException('环境照片须为 URL 数组');
}
const seen = new Set<string>();
const urls: string[] = [];
for (const item of raw) {
const u = String(item ?? '').trim();
if (!u || seen.has(u)) continue;
seen.add(u);
urls.push(u);
}
if (urls.length < MIN_ENV_PHOTOS) {
throw new BadRequestException(`请上传至少 ${MIN_ENV_PHOTOS} 张环境照片`);
}
if (urls.length > MAX_ENV_PHOTOS) {
throw new BadRequestException(`环境照片最多 ${MAX_ENV_PHOTOS}`);
}
return urls;
}
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
@@ -67,6 +96,15 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
case 'benefitUsageRule':
out[field] = normalizeOptionalTextField(raw);
break;
case 'coverUrl': {
const url = String(raw ?? '').trim();
if (!url) throw new BadRequestException('请上传门头照');
out[field] = url;
break;
}
case 'envPhotoUrls':
out[field] = normalizeEnvPhotoUrls(raw);
break;
default:
out[field] = raw == null ? null : String(raw);
}
@@ -74,18 +112,13 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
return out;
}
/** 取 live store 上白名单字段的当前值(用于快照与 diff) */
function pickLiveFields(store: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
const v = store[field];
out[field] = v == null ? null : v;
}
return out;
}
function looseEqual(a: unknown, b: unknown): boolean {
if (a == null && b == null) return true;
if (Array.isArray(a) || Array.isArray(b)) {
const aa = Array.isArray(a) ? a.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
const bb = Array.isArray(b) ? b.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
return aa.length === bb.length && aa.every((x, i) => x === bb[i]);
}
return String(a) === String(b);
}
@@ -111,45 +144,150 @@ export class StoreInfoChangeService {
private readonly partnerCityService: PartnerCityService,
) {}
/** 合伙人 / 门店端 提交基础信息变更 */
private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) {
let coverUrl: string | null = null;
if (coverResourceId) {
const cover = await this.prisma.commonResource.findUnique({
where: { id: coverResourceId },
select: { url: true, status: true },
});
if (cover?.status === 'ACTIVE' && cover.url) coverUrl = cover.url;
}
const envs = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
status: 'ACTIVE',
},
orderBy: { sortOrder: 'asc' },
select: { url: true },
});
return {
coverUrl,
envPhotoUrls: envs.map((e) => e.url).filter(Boolean),
};
}
private pickLiveScalarFields(store: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
const v = store[field];
out[field] = v == null ? null : v;
}
return out;
}
/** 审核通过后写入门头照 / 环境图 */
private async applyApprovedMedia(storeId: bigint, proposed: Record<string, unknown>) {
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { coverResourceId: true },
});
if (!store) throw new NotFoundException('门店不存在');
if ('coverUrl' in proposed) {
const coverUrl = String(proposed.coverUrl ?? '').trim();
if (!coverUrl) throw new BadRequestException('门头照不能为空');
if (store.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: store.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket,
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.store.update({
where: { id: storeId },
data: { coverResourceId: cover.id },
});
}
}
if ('envPhotoUrls' in proposed) {
const urls = normalizeEnvPhotoUrls(proposed.envPhotoUrls);
await this.prisma.commonResource.updateMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
status: 'ACTIVE',
},
data: { status: 'DELETED' },
});
for (let i = 0; i < urls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket,
ossKey: urls[i],
url: urls[i],
sortOrder: i,
},
});
}
}
}
/** 合伙人 / 门店端 提交基础信息变更(含门头照/环境图) */
async submitChange(input: {
submitterType: StoreInfoChangeSubmitterType;
submitterId: bigint;
storeId: bigint;
fields: Record<string, unknown>;
}): Promise<StoreInfoChangeRequestDto> {
// 1) 校验归属
let store: Record<string, unknown>;
let store: Record<string, unknown> & { coverResourceId?: bigint | null };
if (input.submitterType === 'PARTNER') {
const primary = await this.partnerCityService.resolvePrimaryAccount(input.submitterId);
const found = await this.prisma.store.findFirst({
where: { id: input.storeId, partnerAccountId: primary.id },
});
if (!found) throw new NotFoundException('门店不存在或无权操作');
store = found as unknown as Record<string, unknown>;
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
} else {
// SHOP / HQ_DIRECT_ADMIN:先校验门店绑定/存在
if (input.submitterType === 'SHOP') {
await this.storeService.getShopStore(input.submitterId, input.storeId);
}
const found = await this.prisma.store.findUnique({ where: { id: input.storeId } });
if (!found) throw new NotFoundException('门店不存在');
store = found as unknown as Record<string, unknown>;
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
}
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可提交变更');
}
if (String(store.auditStatus || '') === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可提交变更');
}
// 2) 规整 proposed + diff
const proposed = buildProposedSnapshot(input.fields);
const live = pickLiveFields(store);
const media = await this.loadLiveMediaFields(
input.storeId,
store.coverResourceId != null ? BigInt(store.coverResourceId as never) : null,
);
const live: Record<string, unknown> = {
...this.pickLiveScalarFields(store),
coverUrl: media.coverUrl,
envPhotoUrls: media.envPhotoUrls,
};
const changedFields = computeChangedFields(live, proposed);
if (changedFields.length === 0) {
throw new BadRequestException('没有检测到需要变更的字段');
}
// 3) 基础校验
if (proposed.name != null && !String(proposed.name).trim()) {
throw new BadRequestException('请填写门店名称');
}
@@ -172,7 +310,6 @@ export class StoreInfoChangeService {
throw new BadRequestException('经纬度须同时提供');
}
// 4) 同门店已有 PENDING 则替换(最新优先)
await this.prisma.storeInfoChangeRequest.deleteMany({
where: { storeId: input.storeId, status: 'PENDING' },
});
@@ -195,7 +332,6 @@ export class StoreInfoChangeService {
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
}
/** 合伙人端查看本门店历史变更 */
async listPartnerRequests(
storeId: bigint,
partnerAccountId: bigint,
@@ -214,7 +350,6 @@ export class StoreInfoChangeService {
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
}
/** 总部列表 */
async adminList(opts: {
status?: StoreInfoChangeStatus;
page?: number;
@@ -241,7 +376,6 @@ export class StoreInfoChangeService {
return { items, total, page, pageSize };
}
/** 总部待审总数(与套餐审核汇总,用于统一 badge) */
async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> {
const [infoPending, packagePending] = await Promise.all([
this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }),
@@ -250,7 +384,6 @@ export class StoreInfoChangeService {
return { pendingCount: infoPending, packagePendingCount: packagePending };
}
/** 总部详情(含字段级 diff) */
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
const row = await this.prisma.storeInfoChangeRequest.findUnique({
where: { id },
@@ -272,7 +405,6 @@ export class StoreInfoChangeService {
return { ...dto, diffs };
}
/** 总部审核通过/驳回 */
async audit(input: {
id: bigint;
action: 'APPROVE' | 'REJECT';
@@ -299,15 +431,19 @@ export class StoreInfoChangeService {
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
}
// APPROVE:将 proposedSnapshot 写入 Store(白名单内)
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
const data: Record<string, unknown> = {};
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
if (!(field in proposed)) continue;
const v = proposed[field];
data[field] = v == null ? null : v;
}
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
if (Object.keys(data).length) {
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
}
if ('coverUrl' in proposed || 'envPhotoUrls' in proposed) {
await this.applyApprovedMedia(row.storeId, proposed);
}
const updated = await this.prisma.storeInfoChangeRequest.update({
where: { id: input.id },
@@ -1,6 +1,7 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -13,14 +14,18 @@ import {
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type PackageInput = Record<string, unknown>;
@Injectable()
export class StorePackageService {
private readonly logger = new Logger(StorePackageService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storeService: StoreService,
private readonly wecomPush: WecomMessagePushService,
) {}
normalizePackages(raw: unknown): StorePackageItemDto[] {
@@ -181,6 +186,34 @@ export class StorePackageService {
submitterId,
},
});
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { name: true, cityName: true },
});
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
void this.wecomPush
.dispatchMarkdown(
'store.package_audit_pending',
[
'**套餐变更待审核**',
`门店:${store?.name ?? storeId}`,
store?.cityName ? `城市:${store.cityName}` : null,
`提交端:${submitterLabel}`,
`套餐条数:${packages.length}`,
`时间:${now}`,
]
.filter(Boolean)
.join('\n'),
{ applyMention: false },
)
.catch((e) =>
this.logger.warn(
`store.package_audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
),
);
return serializeBigInt({
id: req.id.toString(),
status: req.status,
@@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
@@ -22,6 +23,7 @@ import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -61,6 +63,7 @@ function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number
@Injectable()
export class StoreService {
private readonly config = loadAppConfig();
private readonly logger = new Logger(StoreService.name);
constructor(
private readonly prisma: PrismaService,
@@ -70,8 +73,33 @@ export class StoreService {
private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
private readonly wecomPush: WecomMessagePushService,
) {}
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
private notifyStoreAuditPending(opts: {
storeName: string;
cityName?: string | null;
partnerLabel?: string | null;
submitType: '新建' | '重提';
}) {
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const lines = [
`**门店审核待处理 · ${opts.submitType}**`,
`门店:${opts.storeName}`,
opts.cityName ? `城市:${opts.cityName}` : null,
opts.partnerLabel ? `合伙人:${opts.partnerLabel}` : null,
`时间:${now}`,
].filter(Boolean);
void this.wecomPush
.dispatchMarkdown('store.audit_pending', lines.join('\n'), { applyMention: false })
.catch((e) =>
this.logger.warn(
`store.audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
),
);
}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
@@ -559,6 +587,19 @@ export class StoreService {
extraJson: { storeName: store.name, phone: normalizedPhone },
});
if (!this.config.autoApproveStore) {
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitType: '新建',
});
}
return serializeBigInt({
store: mapStoreCompat({
...store,
@@ -716,6 +757,16 @@ export class StoreService {
remark: '合伙人修改资料后重新提交审核',
},
});
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeName: updated.name,
cityName: updated.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitType: '重提',
});
}
return this.partnerGetStore(partnerAccountId, storeId);
@@ -739,6 +790,11 @@ export class StoreService {
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
if (store.auditStatus === 'APPROVED') {
throw new BadRequestException(
'已营业门店修改门头照/环境图须通过「提交变更」由总部审核通过后生效',
);
}
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
const hasEnv = body.envPhotoUrls !== undefined;
@@ -825,6 +881,16 @@ export class StoreService {
remark: '合伙人重新上传资料后重新提交审核',
},
});
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: primaryId },
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
submitType: '重提',
});
}
return this.partnerGetStore(partnerAccountId, storeId);