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

This commit is contained in:
2026-08-19 15:54:51 +08:00
parent 7dd5fdfb12
commit 233ed0af3b
103 changed files with 5764 additions and 185 deletions
@@ -0,0 +1,99 @@
import {
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { StoreInfoChangeService } from './store-info-change.service';
@Controller('partner/stores')
@UseGuards(JwtAuthGuard)
export class PartnerStoreInfoChangeController {
constructor(private readonly svc: StoreInfoChangeService) {}
@Post(':storeId/info-change-request')
submit(
@CurrentUser() user: AuthUser,
@Param('storeId') storeId: string,
@Body() body: Record<string, unknown>,
) {
return this.svc.submitChange({
submitterType: 'PARTNER',
submitterId: user.actorId,
storeId: BigInt(storeId),
fields: body,
});
}
@Get(':storeId/info-change-requests')
list(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
return this.svc.listPartnerRequests(BigInt(storeId), user.actorId);
}
}
@Controller('shop/store')
@UseGuards(JwtAuthGuard, ShopStoreGuard)
export class ShopStoreInfoChangeController {
constructor(private readonly svc: StoreInfoChangeService) {}
@Post('info-change-request')
submit(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
return this.svc.submitChange({
submitterType: 'SHOP',
submitterId: user.actorId,
storeId: user.storeId!,
fields: body,
});
}
}
@Controller('admin/store-info-change-requests')
@UseGuards(HqAuthGuard)
export class AdminStoreInfoChangeController {
constructor(private readonly svc: StoreInfoChangeService) {}
@Get('summary')
summary() {
return this.svc.adminSummary();
}
@Get()
list(
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.svc.adminList({
status: (status as 'PENDING' | 'APPROVED' | 'REJECTED') || undefined,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.svc.adminDetail(BigInt(id));
}
@Put(':id/audit')
audit(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string },
) {
return this.svc.audit({
id: BigInt(id),
action: body.action,
rejectReason: body.rejectReason,
reviewerId: user.actorId,
});
}
}
@@ -0,0 +1,341 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import {
isStoreContactPhone,
STORE_CONTACT_PHONE_HINT,
} from '@dukang/domain';
import {
STORE_INFO_CHANGEABLE_FIELDS,
type StoreInfoChangeFieldDiff,
type StoreInfoChangeRequestDto,
type StoreInfoChangeStatus,
type StoreInfoChangeSubmitterType,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
function normalizeOptionalTextField(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s)) return null;
return s;
}
function normalizeBusinessHour(value: unknown): string | null {
if (value == null || String(value).trim() === '') return null;
const s = String(value).trim();
if (!/^\d{1,2}:\d{2}$/.test(s)) {
throw new BadRequestException('营业时间格式应为 HH:MM,如 09:00');
}
return s;
}
function coerceNumberOrNull(value: unknown): number | null {
if (value == null || String(value).trim() === '') return null;
const n = Number(value);
if (!Number.isFinite(n)) throw new BadRequestException('数值字段格式不正确');
return n;
}
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
if (!(field in fields)) continue;
const raw = fields[field];
switch (field) {
case 'latitude':
case 'longitude':
case 'avgPrice':
out[field] = coerceNumberOrNull(raw);
break;
case 'openTime':
case 'closeTime':
case 'openTime2':
case 'closeTime2':
out[field] = normalizeBusinessHour(raw);
break;
case 'intro':
case 'benefitUsageRule':
out[field] = normalizeOptionalTextField(raw);
break;
default:
out[field] = raw == null ? null : String(raw);
}
}
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;
return String(a) === String(b);
}
function computeChangedFields(
live: Record<string, unknown>,
proposed: Record<string, unknown>,
): ChangeableField[] {
const changed: ChangeableField[] = [];
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
if (!(field in proposed)) continue;
if (!looseEqual(live[field], proposed[field])) changed.push(field as ChangeableField);
}
return changed;
}
@Injectable()
export class StoreInfoChangeService {
private readonly logger = new Logger(StoreInfoChangeService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storeService: StoreService,
private readonly partnerCityService: PartnerCityService,
) {}
/** 合伙人 / 门店端 提交基础信息变更 */
async submitChange(input: {
submitterType: StoreInfoChangeSubmitterType;
submitterId: bigint;
storeId: bigint;
fields: Record<string, unknown>;
}): Promise<StoreInfoChangeRequestDto> {
// 1) 校验归属
let store: Record<string, unknown>;
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>;
} 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>;
}
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可提交变更');
}
// 2) 规整 proposed + diff
const proposed = buildProposedSnapshot(input.fields);
const live = pickLiveFields(store);
const changedFields = computeChangedFields(live, proposed);
if (changedFields.length === 0) {
throw new BadRequestException('没有检测到需要变更的字段');
}
// 3) 基础校验
if (proposed.name != null && !String(proposed.name).trim()) {
throw new BadRequestException('请填写门店名称');
}
if (proposed.contactPhone != null && !isStoreContactPhone(String(proposed.contactPhone))) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
if (proposed.intro != null && (String(proposed.intro).length < 2 || String(proposed.intro).length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
if (
proposed.benefitUsageRule != null &&
String(proposed.benefitUsageRule).length > 1000
) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
if (
(proposed.latitude != null || proposed.longitude != null) &&
(proposed.latitude == null || proposed.longitude == null)
) {
throw new BadRequestException('经纬度须同时提供');
}
// 4) 同门店已有 PENDING 则替换(最新优先)
await this.prisma.storeInfoChangeRequest.deleteMany({
where: { storeId: input.storeId, status: 'PENDING' },
});
const created = await this.prisma.storeInfoChangeRequest.create({
data: {
storeId: input.storeId,
status: 'PENDING',
liveSnapshot: live as object,
proposedSnapshot: proposed as object,
changedFields: changedFields as unknown as never,
submitterType: input.submitterType,
submitterId: input.submitterId,
},
});
this.logger.log(
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
);
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
}
/** 合伙人端查看本门店历史变更 */
async listPartnerRequests(
storeId: bigint,
partnerAccountId: bigint,
): Promise<StoreInfoChangeRequestDto[]> {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const owned = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primary.id },
select: { id: true },
});
if (!owned) throw new NotFoundException('门店不存在或无权操作');
const rows = await this.prisma.storeInfoChangeRequest.findMany({
where: { storeId },
orderBy: { createdAt: 'desc' },
take: 20,
});
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
}
/** 总部列表 */
async adminList(opts: {
status?: StoreInfoChangeStatus;
page?: number;
pageSize?: number;
}): Promise<{ items: StoreInfoChangeRequestDto[]; total: number; page: number; pageSize: number }> {
const page = Math.max(1, opts.page || 1);
const pageSize = Math.min(Math.max(opts.pageSize || 20, 1), 100);
const where = opts.status ? { status: opts.status } : {};
const [rows, total] = await Promise.all([
this.prisma.storeInfoChangeRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { store: { select: { name: true } } },
}),
this.prisma.storeInfoChangeRequest.count({ where }),
]);
const items = rows.map((r) =>
serializeBigInt(
this.toDto(r as unknown as Record<string, unknown>, (r as { store?: { name?: string } }).store?.name),
),
);
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' } }),
this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' } }),
]);
return { pendingCount: infoPending, packagePendingCount: packagePending };
}
/** 总部详情(含字段级 diff) */
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
const row = await this.prisma.storeInfoChangeRequest.findUnique({
where: { id },
include: { store: { select: { name: true } } },
});
if (!row) throw new NotFoundException('变更请求不存在');
const dto = this.toDto(
row as unknown as Record<string, unknown>,
(row as { store?: { name?: string } }).store?.name,
);
const live = (row as { liveSnapshot?: Record<string, unknown> }).liveSnapshot || {};
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
const changed = ((row as { changedFields?: ChangeableField[] }).changedFields as ChangeableField[]) || [];
const diffs: StoreInfoChangeFieldDiff[] = changed.map((field) => ({
field,
live: live[field] ?? null,
proposed: proposed[field] ?? null,
}));
return { ...dto, diffs };
}
/** 总部审核通过/驳回 */
async audit(input: {
id: bigint;
action: 'APPROVE' | 'REJECT';
rejectReason?: string;
reviewerId: bigint;
}): Promise<StoreInfoChangeRequestDto> {
const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id: input.id } });
if (!row) throw new NotFoundException('变更请求不存在');
if (row.status !== 'PENDING') {
throw new BadRequestException('该变更请求已处理');
}
if (input.action === 'REJECT') {
const updated = await this.prisma.storeInfoChangeRequest.update({
where: { id: input.id },
data: {
status: 'REJECTED',
rejectReason: normalizeOptionalTextField(input.rejectReason) || '总部驳回',
reviewerId: input.reviewerId,
reviewedAt: new Date(),
},
});
this.logger.log(`Store info change rejected id=${input.id}`);
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) {
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 });
const updated = await this.prisma.storeInfoChangeRequest.update({
where: { id: input.id },
data: {
status: 'APPROVED',
reviewerId: input.reviewerId,
reviewedAt: new Date(),
},
});
this.logger.log(`Store info change approved id=${input.id} storeId=${row.storeId}`);
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
}
private toDto(
row: Record<string, unknown>,
storeName?: string,
): StoreInfoChangeRequestDto {
return {
id: String(row.id),
storeId: String(row.storeId),
storeName,
status: row.status as StoreInfoChangeStatus,
changedFields: ((row.changedFields as ChangeableField[]) || []).map(String) as never,
submitterType: row.submitterType as StoreInfoChangeSubmitterType,
submitterId: String(row.submitterId),
rejectReason: (row.rejectReason as string | null) ?? null,
reviewedAt: row.reviewedAt ? (row.reviewedAt as Date).toISOString() : null,
createdAt: (row.createdAt as Date).toISOString(),
};
}
}
@@ -24,6 +24,12 @@ import {
ShopStorePackageController,
} from './store-package.controller';
import { StorePackageService } from './store-package.service';
import {
AdminStoreInfoChangeController,
PartnerStoreInfoChangeController,
ShopStoreInfoChangeController,
} from './store-info-change.controller';
import { StoreInfoChangeService } from './store-info-change.service';
@Module({
imports: [
@@ -47,8 +53,11 @@ import { StorePackageService } from './store-package.service';
ShopDashboardController,
AdminStorePackageController,
AdminStorePackageAuditController,
PartnerStoreInfoChangeController,
ShopStoreInfoChangeController,
AdminStoreInfoChangeController,
],
providers: [StoreService, StoreCategoryService, StorePackageService],
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService],
exports: [StoreService, StoreCategoryService, StorePackageService],
})
export class StoreModule {}