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
+2
View File
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"predev": "pnpm --dir ../../packages/domain build",
"prebuild": "pnpm --dir ../../packages/domain build",
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
+61
View File
@@ -302,6 +302,18 @@ enum StorePackageSubmitterType {
SHOP
}
enum StoreInfoChangeStatus {
PENDING
APPROVED
REJECTED
}
enum StoreInfoChangeSubmitterType {
PARTNER
SHOP
HQ_DIRECT_ADMIN
}
enum UserSourceType {
ORGANIC
PROMO_CODE
@@ -1179,6 +1191,7 @@ model User {
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
orders Order[]
invoices UserInvoice[]
invoiceTitles UserInvoiceTitle[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
redeemPendingRecords RedeemPendingRecord[]
@@ -1299,6 +1312,7 @@ model Store {
visibilityPhones StoreVisibilityPhone[]
packages StorePackage[]
packageChangeRequests StorePackageChangeRequest[]
infoChangeRequests StoreInfoChangeRequest[]
@@index([cityId, status])
@@index([partnerAccountId])
@@ -1362,6 +1376,52 @@ model StorePackageChangeRequest {
@@map("store_package_change_request")
}
/// 用户发票抬头(C 端"我的 → 发票管理"
model UserInvoiceTitle {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
titleType InvoiceTitleType @map("title_type") // PERSONAL | ENTERPRISE
titleName String @map("title_name") @db.VarChar(128)
taxNo String? @map("tax_no") @db.VarChar(32)
email String? @db.VarChar(128)
phone String? @db.VarChar(20)
addressPhone String? @map("address_phone") @db.VarChar(256)
bankAccount String? @map("bank_account") @db.VarChar(256)
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isDefault])
@@map("user_invoice_title")
}
/// 门店基础信息变更请求(合伙人/门店端提交 → 总部审核)
model StoreInfoChangeRequest {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
status StoreInfoChangeStatus @default(PENDING)
/// 变更前 Store 全量快照
liveSnapshot Json @map("live_snapshot")
/// 提交时希望变更的字段集合(白名单内)
proposedSnapshot Json @map("proposed_snapshot")
/// 变更的字段名数组
changedFields Json @map("changed_fields")
submitterType StoreInfoChangeSubmitterType @map("submitter_type")
submitterId BigInt @map("submitter_id") @db.UnsignedBigInt
rejectReason String? @map("reject_reason") @db.VarChar(512)
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@index([storeId, status])
@@index([status, createdAt])
@@map("store_info_change_request")
}
model StoreAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
@@ -1495,6 +1555,7 @@ model Order {
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@index([isTest])
@@index([status, payExpireAt])
@@map("user_order")
}
@@ -44,6 +44,23 @@ export function mapOrderCompat<T extends OrderLike & {
};
}
/** C 端订单列表/详情:附带最新发票状态,不把 invoices 原样透出 */
export function mapUserOrderWithInvoice<
T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
invoices?: { status: string }[];
},
>(order: T) {
const { invoices, ...rest } = order;
return {
...mapOrderCompat(rest),
invoiceStatus: invoices?.[0]?.status ?? null,
};
}
/** 对外联系电话;未单独配置时回退登录手机号 */
export function resolveStoreContactPhone(store: {
phone?: string | null;
@@ -55,10 +55,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
dedupeTtlSec: 120,
});
}
const resObj =
typeof res === 'object' && res !== null ? (res as Record<string, unknown>) : null;
const reason = (resObj?.reason as string | undefined) ?? null;
response.status(status).json({
code: status,
message: msgText,
data: null,
...(reason != null ? { reason } : {}),
});
return;
}
@@ -9,8 +9,8 @@ import { JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ok = super.canActivate(context);
async canActivate(context: ExecutionContext): Promise<boolean> {
const ok = await super.canActivate(context);
if (!ok) return false;
const req = context.switchToHttp().getRequest();
@@ -1,11 +1,13 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
export interface AuthUser {
actorType: string;
@@ -17,11 +19,20 @@ export interface AuthUser {
storeId?: bigint;
}
/**
* v3.5.1 #9:门店/合伙人账号停用或解绑后,运行时接口强制拦截。
* 仅对 STORE / PARTNER actor 做 DB 状态校验;USER / HQ 跳过(不影响 C 端/小程序/总部)。
* 不通过时抛 ForbiddenException({ reason: 'ACCOUNT_DISABLED' }),由 HttpExceptionFilter 透传 reason
* 前端据此 clearAuth() 并跳登录页。
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(protected readonly jwtService: JwtService) {}
constructor(
protected readonly jwtService: JwtService,
protected readonly prisma: PrismaService,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const auth = req.headers.authorization as string | undefined;
if (!auth?.startsWith('Bearer ')) {
@@ -47,10 +58,81 @@ export class JwtAuthGuard implements CanActivate {
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
return true;
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('Invalid token');
}
// 账号启停 / 绑定态校验(仅门店与合伙人)
await this.assertAccountActive(req.user);
return true;
}
private async assertAccountActive(user: AuthUser): Promise<void> {
if (user.actorType === 'STORE') {
const acc = await this.prisma.storeAccount.findUnique({
where: { id: user.actorId },
select: { status: true, isTest: true },
});
if (!acc) {
throw new ForbiddenException({
reason: 'ACCOUNT_DISABLED',
message: '门店账号不存在或已停用,请重新登录',
});
}
// 测试门店账号跳过强校验,避免测试环境自锁
if (acc.isTest) return;
if (acc.status !== 'ACTIVE') {
throw new ForbiddenException({
reason: 'ACCOUNT_DISABLED',
message: '门店账号已停用,请重新登录',
});
}
if (user.storeId != null) {
const store = await this.prisma.store.findUnique({
where: { id: user.storeId },
select: { status: true, isTest: true },
});
if (!store) {
throw new ForbiddenException({
reason: 'ACCOUNT_DISABLED',
message: '门店不存在或已关闭,请重新登录',
});
}
if (!store.isTest && store.status !== 'OPEN') {
throw new ForbiddenException({
reason: 'ACCOUNT_DISABLED',
message: '门店已停用或关闭,请重新登录',
});
}
}
return;
}
if (user.actorType === 'PARTNER') {
const acc = await this.prisma.partnerAccount.findUnique({
where: { id: user.actorId },
select: { status: true, bindingStatus: true, isTest: true },
});
if (!acc) {
throw new ForbiddenException({
reason: 'ACCOUNT_DISABLED',
message: '合伙人账号不存在或已停用,请重新登录',
});
}
// 测试合伙人账号跳过强校验,避免测试环境自锁
if (acc.isTest) return;
if (acc.status !== 'ACTIVE' || acc.bindingStatus !== 'ACTIVE') {
throw new ForbiddenException({
reason: 'ACCOUNT_DISABLED',
message: '合伙人账号已停用或解绑,请重新登录',
});
}
return;
}
// USER / HQ 不在此校验(按需求仅门店 + 合伙人)
return;
}
}
@@ -15,7 +15,7 @@ export class PartnerPrimaryGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
this.jwtAuthGuard.canActivate(context);
await this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
+2 -1
View File
@@ -6,6 +6,7 @@ import { SettlementModule } from '../modules/settlement/settlement.module';
import { DeliveryProcessor } from './delivery.processor';
import { SettlementScheduler } from './settlement.scheduler';
import { MonitorScheduler } from './monitor.scheduler';
import { OrderExpiryScheduler } from './order-expiry.scheduler';
import { DELIVERY_QUEUE } from './jobs.constants';
@Module({
@@ -15,6 +16,6 @@ import { DELIVERY_QUEUE } from './jobs.constants';
TradeModule,
SettlementModule,
],
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler],
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler, OrderExpiryScheduler],
})
export class JobsModule {}
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { TradeService } from '../modules/trade/trade.service';
import { AlertService } from '../common/alert/alert.service';
/**
* v3.5.1 #8:订单 30 分钟未支付自动取消。
* 每分钟扫描一次:将 status=PENDING_PAY 且 payExpireAt 已过期且非测试订单 翻为 CANCELLED。
* 下单时 trade.service 已写入 payExpireAt = now+30min,无需新增字段。
*/
@Injectable()
export class OrderExpiryScheduler {
private readonly logger = new Logger(OrderExpiryScheduler.name);
constructor(
private readonly trade: TradeService,
private readonly alert: AlertService,
) {}
@Cron('*/1 * * * *', { timeZone: 'Asia/Shanghai' })
async handleExpiredPendingOrders() {
try {
const n = await this.trade.cancelExpiredPendingOrders(200);
if (n > 0) this.logger.log(`Auto-cancelled ${n} expired pending orders`);
} catch (e) {
this.logger.error('Order expiry job failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P1',
category: 'job',
title: '订单自动取消任务失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_order_expiry_fail|${new Date().toISOString().slice(0, 10)}`,
});
}
}
}
@@ -20,6 +20,12 @@ export class AdminOrdersController {
return this.ordersService.list(query);
}
/** 必须写在 :id 之前,否则 big-screen 会被当成订单 ID */
@Get('big-screen')
bigScreen(@Query('limit') limit?: string) {
return this.ordersService.listBigScreen(limit);
}
@Post('batch-delete')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@@ -1,3 +1,4 @@
import { maskContactPhone } from '@dukang/domain';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -24,6 +25,34 @@ export class AdminOrdersService {
private readonly adminRedeemService: AdminRedeemService,
) {}
/** v3.5.1 #1:发布会大屏,返回全部订单(按时间倒序,上限 2000) */
async listBigScreen(limit?: string) {
const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000);
const orders = await this.prisma.order.findMany({
where: {
isTest: false,
payAmount: { gte: 100 },
},
orderBy: { createdAt: 'desc' },
take,
include: { user: { select: { phone: true } } },
});
return {
items: orders.map((o) => {
const spec = o.productSpec ? ` ${o.productSpec}` : '';
const phone = o.user?.phone || o.receiverPhone;
return {
id: o.id.toString(),
orderNo: o.orderNo,
payAmount: Number(o.payAmount),
items: `${o.productName}${spec} × ${o.quantity}`,
createdAt: o.createdAt.toISOString(),
userPhoneMasked: phone ? maskContactPhone(phone) : null,
};
}),
};
}
async list(query: AdminOrdersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
@@ -96,12 +96,22 @@ export class AdminStoresService {
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
const pendingByStore = new Map<string, string>();
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
const pendingInfoByStore = new Map<string, string>();
if (items.length) {
const pendingReqs = await this.prisma.storePackageChangeRequest.findMany({
where: { storeId: { in: items.map((s) => s.id) }, status: 'PENDING' },
select: { id: true, storeId: true },
});
const storeIds = items.map((s) => s.id);
const [pendingReqs, pendingInfoReqs] = await Promise.all([
this.prisma.storePackageChangeRequest.findMany({
where: { storeId: { in: storeIds }, status: 'PENDING' },
select: { id: true, storeId: true },
}),
this.prisma.storeInfoChangeRequest.findMany({
where: { storeId: { in: storeIds }, status: 'PENDING' },
select: { id: true, storeId: true },
}),
]);
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
}
return serializeBigInt({
@@ -113,6 +123,7 @@ export class AdminStoresService {
visibilityPhones: visibilityPhones.map((p) => p.phone),
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
@@ -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 {}
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
export class CreateAfterSaleTicketDto {
@@ -37,18 +38,26 @@ export class CreatePackageDisputeDto {
}
export class CreateInvoiceDto {
/** v3.5.1:从已保存的发票抬头中选择(若存在则覆盖下方抬头字段) */
@IsOptional()
@IsString()
titleId?: string;
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
@IsString()
@IsIn(['PERSONAL', 'ENTERPRISE'])
titleType: string;
titleType?: string;
@IsOptional()
@IsString()
@IsIn(['NORMAL', 'SPECIAL'])
invoiceKind: string;
invoiceKind?: string;
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
@IsString()
@IsNotEmpty()
@MaxLength(128)
titleName: string;
titleName?: string;
@IsOptional()
@IsString()
@@ -65,13 +74,15 @@ export class CreateInvoiceDto {
@MaxLength(256)
bankAccount?: string;
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
@IsEmail()
email: string;
email?: string;
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
@IsString()
@IsNotEmpty()
@MaxLength(20)
phone: string;
phone?: string;
@IsOptional()
@IsString()
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
type UpsertInvoiceTitleRequest,
type UserInvoiceTitleDto,
} from '@dukang/shared-types';
import { InvoiceTitleService } from './invoice-title.service';
import { TradeService } from './trade.service';
@Controller('trade')
@UseGuards(JwtAuthGuard)
export class TradeInvoiceTitleController {
constructor(
private readonly titleSvc: InvoiceTitleService,
private readonly tradeService: TradeService,
) {}
@Get('invoice-titles')
list(@CurrentUser() user: AuthUser): Promise<UserInvoiceTitleDto[]> {
return this.titleSvc.listTitles(user.actorId);
}
@Post('invoice-titles')
create(
@CurrentUser() user: AuthUser,
@Body() body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
return this.titleSvc.createTitle(user.actorId, body);
}
@Put('invoice-titles/:id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
return this.titleSvc.updateTitle(user.actorId, BigInt(id), body);
}
@Delete('invoice-titles/:id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.titleSvc.deleteTitle(user.actorId, BigInt(id));
}
/** 订单可用的发票抬头(用户全部抬头) */
@Get('orders/:orderId/invoice-titles')
orderTitles(@CurrentUser() user: AuthUser): Promise<UserInvoiceTitleDto[]> {
return this.titleSvc.listTitles(user.actorId);
}
/** 订单是否已申请发票 */
@Get('orders/:orderId/invoice-status')
orderInvoiceStatus(@CurrentUser() user: AuthUser, @Param('orderId') orderId: string) {
return this.tradeService.getOrderInvoiceStatus(user.actorId, BigInt(orderId));
}
}
@@ -0,0 +1,123 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
type UpsertInvoiceTitleRequest,
type UserInvoiceTitleDto,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@Injectable()
export class InvoiceTitleService {
constructor(private readonly prisma: PrismaService) {}
async listTitles(userId: bigint): Promise<UserInvoiceTitleDto[]> {
const rows = await this.prisma.userInvoiceTitle.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }],
});
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
}
async createTitle(
userId: bigint,
body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
this.validate(body);
if (body.isDefault) {
await this.prisma.userInvoiceTitle.updateMany({
where: { userId, isDefault: true },
data: { isDefault: false },
});
}
const created = await this.prisma.userInvoiceTitle.create({
data: {
userId,
titleType: body.titleType,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
email: body.email?.trim() || null,
phone: body.phone?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
isDefault: !!body.isDefault,
},
});
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
}
async updateTitle(
userId: bigint,
id: bigint,
body: UpsertInvoiceTitleRequest,
): Promise<UserInvoiceTitleDto> {
const existing = await this.prisma.userInvoiceTitle.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('发票抬头不存在');
this.validate(body, true);
const isDefault = body.isDefault ?? existing.isDefault;
if (isDefault && !existing.isDefault) {
await this.prisma.userInvoiceTitle.updateMany({
where: { userId, isDefault: true },
data: { isDefault: false },
});
}
const updated = await this.prisma.userInvoiceTitle.update({
where: { id },
data: {
titleType: body.titleType,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
email: body.email?.trim() || null,
phone: body.phone?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
isDefault,
},
});
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
}
async deleteTitle(userId: bigint, id: bigint): Promise<{ id: string }> {
const existing = await this.prisma.userInvoiceTitle.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('发票抬头不存在');
const referenced = await this.prisma.userInvoice.findFirst({ where: { userId, titleName: existing.titleName } });
if (referenced) {
throw new ConflictException('该抬头已有发票申请记录,无法删除');
}
await this.prisma.userInvoiceTitle.delete({ where: { id } });
return { id: id.toString() };
}
private validate(body: UpsertInvoiceTitleRequest, isUpdate = false) {
if (!body.titleName?.trim()) {
throw new BadRequestException('请填写抬头名称');
}
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
throw new BadRequestException('企业抬头须填写税号');
}
if (isUpdate && body.titleType === undefined) {
throw new BadRequestException('titleType 必填');
}
}
private toDto(row: Record<string, unknown>): UserInvoiceTitleDto {
return {
id: String(row.id),
titleType: row.titleType as UserInvoiceTitleDto['titleType'],
titleName: String(row.titleName),
taxNo: (row.taxNo as string | null) ?? null,
email: (row.email as string | null) ?? null,
phone: (row.phone as string | null) ?? null,
addressPhone: (row.addressPhone as string | null) ?? null,
bankAccount: (row.bankAccount as string | null) ?? null,
isDefault: !!row.isDefault,
createdAt: (row.createdAt as Date).toISOString(),
};
}
}
@@ -18,6 +18,8 @@ import {
TradePackageDisputeController,
} from './trade.controller';
import { TradeService } from './trade.service';
import { TradeInvoiceTitleController } from './invoice-title.controller';
import { InvoiceTitleService } from './invoice-title.service';
@Module({
imports: [
@@ -39,8 +41,9 @@ import { TradeService } from './trade.service';
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
TradeInvoiceTitleController,
],
providers: [TradeService],
providers: [TradeService, InvoiceTitleService],
exports: [TradeService],
})
export class TradeModule {}
@@ -2,6 +2,7 @@ import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
@@ -29,7 +30,7 @@ import { IpGeoService } from '../../common/geo/ip-geo.service';
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
import { extractClientIp } from '../../common/geo/client-ip.util';
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { mapOrderCompat, mapStatusLogCompat, mapUserOrderWithInvoice } from '../../common/compat/v31-compat';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import { AlertService } from '../../common/alert/alert.service';
@@ -57,6 +58,9 @@ export class TradeService {
private readonly alert: AlertService,
) {}
private readonly logger = new Logger(TradeService.name);
async preview(
userId: bigint,
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
@@ -753,14 +757,23 @@ export class TradeService {
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
include: { benefitCoupon: true, imageResource: true },
include: {
benefitCoupon: true,
imageResource: true,
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
return {
list: serializeBigInt(list.map(mapUserOrderWithInvoice)),
total,
page,
pageSize,
};
}
async getOrder(userId: bigint, orderId: bigint) {
@@ -772,6 +785,7 @@ export class TradeService {
imageResource: true,
product: true,
fulfillmentWarehouse: { select: { id: true, name: true } },
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!order) throw new NotFoundException('订单不存在');
@@ -779,7 +793,10 @@ export class TradeService {
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
const mapped = mapUserOrderWithInvoice({
...order,
statusLogs: mapStatusLogCompat(statusLogs),
});
return serializeBigInt({
...mapped,
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
@@ -987,14 +1004,15 @@ export class TradeService {
userId: bigint,
orderId: bigint,
body: {
titleType: string;
invoiceKind: string;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
titleId?: string;
titleType?: string;
invoiceKind?: string;
titleName?: string;
taxNo?: string | null;
addressPhone?: string | null;
bankAccount?: string | null;
email?: string;
phone?: string;
remark?: string;
},
) {
@@ -1009,14 +1027,49 @@ export class TradeService {
});
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
let resolved = {
titleType: body.titleType || '',
titleName: body.titleName || '',
taxNo: body.taxNo ?? null,
addressPhone: body.addressPhone ?? null,
bankAccount: body.bankAccount ?? null,
email: body.email || '',
phone: body.phone || '',
};
if (body.titleId) {
const title = await this.prisma.userInvoiceTitle.findFirst({
where: { id: BigInt(body.titleId), userId },
});
if (!title) throw new BadRequestException('发票抬头不存在');
resolved = {
titleType: title.titleType,
titleName: title.titleName,
taxNo: title.taxNo,
addressPhone: title.addressPhone,
bankAccount: title.bankAccount,
email: title.email || body.email || '',
phone: title.phone || body.phone || '',
};
}
if (!resolved.titleName.trim()) {
throw new BadRequestException('请填写抬头名称');
}
if (!resolved.email.trim()) {
throw new BadRequestException('请填写接收邮箱');
}
if (!resolved.phone.trim()) {
throw new BadRequestException('请填写联系电话');
}
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
throw new BadRequestException('企业抬头须填写税号');
}
if (body.invoiceKind === 'SPECIAL') {
if (body.titleType !== 'ENTERPRISE') {
const invoiceKind = body.invoiceKind || 'NORMAL';
if (invoiceKind === 'SPECIAL') {
if (resolved.titleType !== 'ENTERPRISE') {
throw new BadRequestException('专用发票仅支持企业抬头');
}
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
if (!resolved.taxNo?.trim() || !resolved.addressPhone?.trim() || !resolved.bankAccount?.trim()) {
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
}
}
@@ -1026,20 +1079,29 @@ export class TradeService {
invoiceNo: this.generateInvoiceNo(),
orderId,
userId,
titleType: body.titleType as never,
invoiceKind: body.invoiceKind as never,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
email: body.email.trim(),
phone: body.phone.trim(),
titleType: resolved.titleType as never,
invoiceKind: invoiceKind as never,
titleName: resolved.titleName.trim(),
taxNo: resolved.taxNo?.trim() || null,
addressPhone: resolved.addressPhone?.trim() || null,
bankAccount: resolved.bankAccount?.trim() || null,
email: resolved.email.trim(),
phone: resolved.phone.trim(),
remark: body.remark?.trim() || null,
},
});
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
}
/** v3.5.1 #3:查询某订单是否已申请发票 */
async getOrderInvoiceStatus(userId: bigint, orderId: bigint) {
const inv = await this.prisma.userInvoice.findFirst({
where: { orderId, userId },
select: { id: true, status: true },
});
return { exists: !!inv, status: inv?.status ?? null };
}
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
const where = { userId };
const [items, total] = await Promise.all([
@@ -1096,14 +1158,15 @@ export class TradeService {
async adminCreateInvoice(
body: {
orderNo: string;
titleType: string;
invoiceKind: string;
titleName: string;
titleId?: string;
titleType?: string;
invoiceKind?: string;
titleName?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
email?: string;
phone?: string;
remark?: string;
},
) {
@@ -1819,6 +1882,59 @@ export class TradeService {
return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
}
/**
* v3.5.1 #8:订单 30 分钟未支付自动取消。
* 下单时已写入 payExpireAt = now+30min;此处将已过期且仍为待支付的订单翻为 CANCELLED。
* 建单/取消均无库存占用或权益发放,故仅翻状态 + 写状态日志,不回滚权益券。
*/
async cancelExpiredPendingOrders(limit = 200): Promise<number> {
const now = new Date();
const expired = await this.prisma.order.findMany({
where: {
status: 'PENDING_PAY',
payStatus: 'UNPAID',
payExpireAt: { lt: now },
isTest: false,
},
take: limit,
select: { id: true },
orderBy: { payExpireAt: 'asc' },
});
if (expired.length === 0) return 0;
let cancelled = 0;
for (const o of expired) {
try {
await this.prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: o.id, status: 'PENDING_PAY' },
data: { status: 'CANCELLED', cancelledAt: now },
});
if (updated.count === 0) return; // 已被并发处理
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId: o.id,
fromStatus: 'PENDING_PAY',
toStatus: 'CANCELLED',
operator: 'SYSTEM_AUTO_EXPIRE',
remark: '30 分钟未支付自动取消',
}),
});
});
cancelled++;
} catch (e) {
this.logger.error(
`cancelExpiredPendingOrders failed for order ${o.id}`,
e instanceof Error ? e.stack : e,
);
}
}
if (cancelled > 0) {
this.logger.log(`Auto-cancelled ${cancelled} expired pending orders`);
}
return cancelled;
}
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
async getHqProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
+2 -1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "Node16",
"moduleResolution": "node16",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,