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
@@ -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);