feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
@@ -0,0 +1,105 @@
import { BadRequestException, Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoTouchDto } from './dto/promo.dto';
import {
TrackPartnerEventsDto,
TrackStoreEventsDto,
TrackUserEventsDto,
} from './dto/track-events.dto';
import { ActorType, ClientApp } from '@dukang/shared-types';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('events')
@UseGuards(OptionalJwtAuthGuard)
track(@CurrentUser() user: AuthUser | undefined, @Body() body: TrackUserEventsDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
const clientApp = user?.clientApp ?? body.clientApp ?? ClientApp.USER_H5;
return this.analyticsService.trackBatchOptional(userId, clientApp, body.events, body.sessionId);
}
@Post('store-events')
@UseGuards(JwtAuthGuard)
trackStore(@CurrentUser() user: AuthUser, @Body() body: TrackStoreEventsDto) {
if (user.actorType !== ActorType.STORE) {
throw new BadRequestException('仅门店端可上报');
}
const storeId = body.storeId ? BigInt(body.storeId) : user.storeId;
if (storeId == null) throw new BadRequestException('缺少门店信息');
return this.analyticsService.trackStoreBatch(
user.actorId,
storeId,
user.clientApp,
body.events,
body.sessionId,
);
}
@Post('partner-events')
@UseGuards(JwtAuthGuard)
trackPartner(@CurrentUser() user: AuthUser, @Body() body: TrackPartnerEventsDto) {
if (user.actorType !== ActorType.PARTNER) {
throw new BadRequestException('仅合伙人端可上报');
}
return this.analyticsService.trackPartnerBatch(
user.actorId,
user.actorId,
user.clientApp,
body.events,
body.sessionId,
);
}
}
@Controller('promo')
export class PromoController {
constructor(
private readonly promoCodeService: PromoCodeService,
private readonly analyticsService: AnalyticsService,
) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
async touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
const result = await this.promoCodeService.touch(
{
promoCode: dto.promoCode,
qrcodeId: dto.qrcodeId,
promoId: dto.promoId,
countScan: dto.countScan,
},
userId,
);
void this.analyticsService.trackBatchOptional(
userId,
user?.clientApp ?? ClientApp.USER_H5,
[
{
eventName: 'promo_touch',
params: {
sessionId: dto.sessionId,
promoCode: result.promoCode,
promoCodeId: result.promoCodeId,
channelName: result.channelName,
attributed: result.attributed,
sourceApplied: result.sourceApplied,
scanCounted: result.scanCounted,
sourceType: 'PROMO_CODE',
sourceRefId: result.promoCodeId,
},
},
],
dto.sessionId,
);
return result;
}
}
@@ -0,0 +1,15 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { PromoModule } from '../promo/promo.module';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@Module({
imports: [forwardRef(() => IamModule), PromoModule],
controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -0,0 +1,239 @@
import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
export type TrackEventInput = {
eventName: string;
pagePath?: string;
refType?: string;
refId?: bigint;
sessionId?: string;
sourceType?: string;
sourceRefId?: bigint;
extraJson?: Record<string, unknown>;
};
export type TrackStoreEventInput = TrackEventInput & {
storeAccountId?: bigint;
storeId?: bigint;
};
export type TrackPartnerEventInput = TrackEventInput & {
partnerAccountId: bigint;
};
type RawEvent = { eventName: string; params?: Record<string, unknown> };
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async trackBatchOptional(
userId: bigint | null | undefined,
clientApp: string,
events: RawEvent[],
sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logUserAnalytics.createMany({
data: events.map((e) =>
this.toUserRow(userId ?? null, clientApp, {
eventName: e.eventName,
...this.parseParams(e.params, sessionId),
}),
),
});
return { count: events.length };
}
async trackBatch(userId: bigint, clientApp: string, events: RawEvent[], sessionId?: string) {
return this.trackBatchOptional(userId, clientApp, events, sessionId);
}
async trackStoreBatch(
storeAccountId: bigint | undefined,
storeId: bigint,
clientApp: ClientApp | string,
events: RawEvent[],
sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logStoreAnalytics.createMany({
data: events.map((e) =>
this.toStoreRow(storeAccountId, clientApp, {
storeId,
eventName: e.eventName,
...this.parseParams(e.params, sessionId),
}),
),
});
return { count: events.length };
}
async trackPartnerBatch(
actorAccountId: bigint | undefined,
partnerAccountId: bigint,
clientApp: ClientApp | string,
events: RawEvent[],
sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logPartnerAnalytics.createMany({
data: events.map((e) =>
this.toPartnerRow(actorAccountId, clientApp, {
partnerAccountId,
eventName: e.eventName,
...this.parseParams(e.params, sessionId),
}),
),
});
return { count: events.length };
}
async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
await this.prisma.logUserAnalytics.create({
data: this.toUserRow(userId, clientApp, event),
});
}
trackOneSafe(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
void this.trackOne(userId, clientApp, event).catch(() => {});
}
trackOneSafeOptional(
userId: bigint | null | undefined,
clientApp: ClientApp | string,
event: TrackEventInput,
) {
void this.trackBatchOptional(
userId,
clientApp,
[{ eventName: event.eventName, params: this.eventToParams(event) }],
event.sessionId,
).catch(() => {});
}
async trackStoreOne(
storeAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackStoreEventInput,
) {
if (event.storeId == null) return;
await this.prisma.logStoreAnalytics.create({
data: this.toStoreRow(storeAccountId, clientApp, event),
});
}
trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
if (event.storeId == null) return;
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
}
async trackPartnerOne(
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
await this.prisma.logPartnerAnalytics.create({
data: this.toPartnerRow(actorAccountId, clientApp, event),
});
}
trackPartnerOneSafe(
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
void this.trackPartnerOne(actorAccountId, clientApp, event).catch(() => {});
}
private parseParams(
params?: Record<string, unknown>,
fallbackSessionId?: string,
): Omit<TrackEventInput, 'eventName'> {
const p = params ?? {};
const sessionId =
typeof p.sessionId === 'string' ? p.sessionId.slice(0, 64) : fallbackSessionId?.slice(0, 64);
const pagePath = typeof p.pagePath === 'string' ? p.pagePath.slice(0, 128) : undefined;
const refType = typeof p.refType === 'string' ? p.refType : undefined;
const refId = p.refId != null ? BigInt(String(p.refId)) : undefined;
const sourceType = typeof p.sourceType === 'string' ? p.sourceType.slice(0, 32) : undefined;
const sourceRefId = p.sourceRefId != null ? BigInt(String(p.sourceRefId)) : undefined;
const {
sessionId: _s,
pagePath: _p,
refType: _rt,
refId: _ri,
sourceType: _st,
sourceRefId: _sr,
...rest
} = p;
return {
sessionId,
pagePath,
refType,
refId,
sourceType,
sourceRefId,
extraJson: Object.keys(rest).length ? rest : undefined,
};
}
private eventToParams(event: TrackEventInput): Record<string, unknown> {
return {
...(event.pagePath ? { pagePath: event.pagePath } : {}),
...(event.refType ? { refType: event.refType } : {}),
...(event.refId != null ? { refId: event.refId.toString() } : {}),
...(event.sessionId ? { sessionId: event.sessionId } : {}),
...(event.sourceType ? { sourceType: event.sourceType } : {}),
...(event.sourceRefId != null ? { sourceRefId: event.sourceRefId.toString() } : {}),
...(event.extraJson ?? {}),
};
}
private toUserRow(userId: bigint | null, clientApp: ClientApp | string, event: TrackEventInput) {
return {
userId,
sessionId: event.sessionId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
pagePath: event.pagePath,
refType: event.refType,
refId: event.refId,
sourceType: event.sourceType,
sourceRefId: event.sourceRefId,
extraJson: event.extraJson as never,
};
}
private toStoreRow(
storeAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackStoreEventInput,
) {
return {
storeAccountId,
storeId: event.storeId!,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
refType: event.refType,
refId: event.refId,
extraJson: event.extraJson as never,
};
}
private toPartnerRow(
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
return {
partnerAccountId: event.partnerAccountId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
refType: event.refType,
refId: event.refId ?? actorAccountId,
extraJson: event.extraJson as never,
};
}
}
@@ -0,0 +1,34 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class PromoTouchDto {
@IsOptional()
@IsString()
promoCode?: string;
@IsOptional()
@IsString()
qrcodeId?: string;
/** 小程序码 scene 中的推广活动 ID */
@IsOptional()
@IsString()
promoId?: string;
/**
* 是否累加扫码次数。扫码进入为 true;登录后归因可传 false,避免重复计数。
* 默认 true。
*/
@IsOptional()
@Transform(({ value }) => {
if (value === false || value === 'false' || value === 0 || value === '0') return false;
if (value === true || value === 'true' || value === 1 || value === '1') return true;
return undefined;
})
@IsBoolean()
countScan?: boolean;
@IsOptional()
@IsString()
sessionId?: string;
}
@@ -0,0 +1,56 @@
import { IsArray, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class AnalyticsEventDto {
@IsString()
@MaxLength(64)
eventName!: string;
@IsOptional()
params?: Record<string, unknown>;
}
export class TrackUserEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AnalyticsEventDto)
events!: AnalyticsEventDto[];
@IsOptional()
@IsString()
@MaxLength(64)
sessionId?: string;
@IsOptional()
@IsString()
@MaxLength(32)
clientApp?: string;
}
export class TrackStoreEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AnalyticsEventDto)
events!: AnalyticsEventDto[];
@IsOptional()
@IsString()
@MaxLength(64)
sessionId?: string;
@IsOptional()
@IsString()
storeId?: string;
}
export class TrackPartnerEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AnalyticsEventDto)
events!: AnalyticsEventDto[];
@IsOptional()
@IsString()
@MaxLength(64)
sessionId?: string;
}
@@ -0,0 +1,33 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { BenefitService } from './benefit.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('benefit')
@UseGuards(JwtAuthGuard)
export class BenefitController {
constructor(private readonly benefitService: BenefitService) {}
@Get('coupons')
coupons(@CurrentUser() user: AuthUser) {
return this.benefitService.listCoupons(user.actorId);
}
@Get('summary')
summary(@CurrentUser() user: AuthUser) {
return this.benefitService.getSummary(user.actorId);
}
@Get('coupons/:id')
coupon(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.benefitService.getCoupon(user.actorId, BigInt(id));
}
@Get('ledger')
ledger(@CurrentUser() user: AuthUser, @Query('couponId') couponId?: string) {
return this.benefitService.getLedger(
user.actorId,
couponId ? BigInt(couponId) : undefined,
);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { BenefitController } from './benefit.controller';
import { BenefitService } from './benefit.service';
@Module({
imports: [IamModule],
controllers: [BenefitController],
providers: [BenefitService],
exports: [BenefitService],
})
export class BenefitModule {}
@@ -0,0 +1,230 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
export type CouponAllocation = { couponId: string; amount: number };
@Injectable()
export class BenefitService {
constructor(private readonly prisma: PrismaService) {}
async grantOnOrderPaid(orderId: bigint) {
const order = await this.prisma.order.findUniqueOrThrow({
where: { id: orderId },
});
const product = await this.prisma.commonProductItem.findUnique({ where: { id: order.productId } });
const unitBenefit = calcBenefitAmount({
price: Number(order.listUnitPrice),
benefitAmount: product?.benefitAmount ? Number(product.benefitAmount) : null,
});
const totalBenefit = unitBenefit * order.quantity;
const coupon = await this.prisma.benefitCoupon.create({
data: {
couponNo: generateCouponNo(),
userId: order.userId,
orderId: order.id,
totalAmount: totalBenefit,
balance: totalBenefit,
sourceProduct: order.productName,
},
});
await this.prisma.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: order.userId,
couponId: coupon.id,
type: 'GRANT',
amount: totalBenefit,
balanceAfter: totalBenefit,
refType: 'ORDER',
refId: order.id,
remark: '购酒赠券',
}),
});
return serializeBigInt(coupon);
}
/** HQ 手动发放权益(无关联订单) */
async grantManual(params: {
userId: bigint;
amount: number;
remark?: string;
sourceProduct?: string;
}) {
const amount = Number(params.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('权益金额须大于 0');
}
if (amount > 999_999.99) {
throw new BadRequestException('权益金额超出上限');
}
const sourceProduct = params.sourceProduct?.trim() || '总部手动发放';
const remark = params.remark?.trim() || '总部手动发放';
const coupon = await this.prisma.$transaction(async (tx) => {
const created = await tx.benefitCoupon.create({
data: {
couponNo: generateCouponNo(),
userId: params.userId,
totalAmount: amount,
balance: amount,
sourceProduct,
},
});
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: params.userId,
couponId: created.id,
type: 'GRANT',
amount,
balanceAfter: amount,
refType: 'ADMIN_GRANT',
remark,
}),
});
return created;
});
return serializeBigInt(coupon);
}
async listCoupons(userId: bigint) {
const list = await this.prisma.benefitCoupon.findMany({
where: { userId, status: { in: ['ACTIVE', 'USED_UP'] } },
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(list);
}
async getSummary(userId: bigint) {
const coupons = await this.prisma.benefitCoupon.findMany({
where: { userId, status: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
});
const summary = calcBenefitSummary(
coupons.map((c) => Number(c.balance)),
);
return serializeBigInt(summary);
}
async getLedger(userId: bigint, couponId?: bigint) {
const list = await this.prisma.commonEvent.findMany({
where: benefitLedgerWhere(userId, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(
list.map((e) => {
const type = e.param1 || '';
const amount = Number(e.amount1 ?? 0);
const title =
e.remark ||
(type === 'REDEEM'
? '门店核销'
: type === 'GRANT'
? '购酒入账'
: type === 'REFUND_VOID'
? '退款作废'
: '权益变动');
return {
id: e.id,
type,
amount,
title,
createdAt: e.createdAt,
balanceAfter: e.amount2 != null ? Number(e.amount2) : null,
};
}),
);
}
async getCoupon(userId: bigint, couponId: bigint) {
const coupon = await this.prisma.benefitCoupon.findFirst({
where: { id: couponId, userId },
});
if (!coupon) return null;
const ledgers = await this.prisma.commonEvent.findMany({
where: benefitLedgerWhere(undefined, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt({ coupon, ledgers });
}
/** 核销扣减券余额(乐观锁),由 redeem 模块调用 */
async deductCoupons(
tx: Prisma.TransactionClient,
allocations: CouponAllocation[],
refType: 'STORE',
refId: bigint,
) {
for (const alloc of allocations) {
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
where: { id: BigInt(alloc.couponId) },
});
const allocAmount = Number(alloc.amount);
if (!Number.isFinite(allocAmount) || allocAmount <= 0) {
throw new Error('BENEFIT_ALLOC_INVALID');
}
const updated = await tx.benefitCoupon.updateMany({
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
data: {
usedAmount: { increment: allocAmount },
balance: { decrement: allocAmount },
version: { increment: 1 },
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
},
});
if (updated.count === 0) throw new Error('BENEFIT_DEDUCT_CONFLICT');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
amount: -allocAmount,
balanceAfter: newBalance,
refType,
refId,
}),
});
}
}
/** 退款作废权益 */
async voidCouponsOnRefund(orderId: bigint) {
const coupons = await this.prisma.benefitCoupon.findMany({
where: { orderId, status: { in: ['ACTIVE', 'USED_UP'] } },
});
for (const coupon of coupons) {
const balance = Number(coupon.balance);
if (balance <= 0 && coupon.status === 'USED_UP') continue;
await this.prisma.$transaction(async (tx) => {
await tx.benefitCoupon.update({
where: { id: coupon.id },
data: { status: 'VOID', balance: 0 },
});
if (balance > 0) {
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REFUND_VOID',
amount: -balance,
balanceAfter: 0,
refType: 'ORDER',
refId: orderId,
remark: '退款作废权益',
}),
});
}
});
}
}
}
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
@Controller('catalog')
export class CatalogController {
constructor(private readonly catalogService: CatalogService) {}
@Get('cities')
cities() {
return this.catalogService.listCities();
}
@Get('products')
@UseGuards(OptionalJwtAuthGuard)
async products(
@CurrentUser() user: AuthUser | undefined,
@Query('aromaType') aromaType?: string,
@Query('cityCode') cityCode?: string,
) {
const viewerPhone = await this.resolveViewerPhone(user);
return this.catalogService.listProducts(aromaType, cityCode, { phone: viewerPhone });
}
@Get('products/:id')
@UseGuards(OptionalJwtAuthGuard)
async product(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {
const viewerPhone = await this.resolveViewerPhone(user);
return this.catalogService.getProduct(BigInt(id), { phone: viewerPhone });
}
private async resolveViewerPhone(user?: AuthUser) {
if (!user || user.actorType !== 'USER') return null;
return this.catalogService.resolveUserPhone(user.actorId);
}
}
@@ -0,0 +1,62 @@
import type { CommonProductItem, CommonResource } from '@prisma/client';
export type ProductMediaDto = {
mainImageUrl: string | null;
carouselUrls: string[];
detailImageUrls: string[];
};
type ProductWithCover = CommonProductItem & {
coverResource?: { url: string } | null;
};
function urlsFromResources(resources: CommonResource[], bizType: 'CAROUSEL' | 'DETAIL') {
return resources
.filter((r) => r.bizType === bizType && r.url)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((r) => r.url);
}
export function mapProductMedia(
product: ProductWithCover,
extraResources: CommonResource[] = [],
): ProductMediaDto {
const mainImageUrl = product.coverResource?.url ?? null;
const carouselFromDb = urlsFromResources(extraResources, 'CAROUSEL');
const detailFromDb = urlsFromResources(extraResources, 'DETAIL');
const detailFromJson = parseDetailContentImages(product.detailContent);
const carouselUrls =
carouselFromDb.length > 0
? carouselFromDb
: mainImageUrl
? [mainImageUrl]
: [];
const detailImageUrls =
detailFromDb.length > 0
? detailFromDb
: detailFromJson;
return { mainImageUrl, carouselUrls, detailImageUrls };
}
function parseDetailContentImages(detailContent: unknown): string[] {
if (!detailContent || typeof detailContent !== 'object') return [];
const record = detailContent as Record<string, unknown>;
const images = record.images ?? record.detailImages ?? record.detailImageUrls;
if (!Array.isArray(images)) return [];
return images.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
export function groupResourcesByProductId(resources: CommonResource[]) {
const map = new Map<string, CommonResource[]>();
for (const resource of resources) {
const key = resource.ownerId.toString();
const list = map.get(key) ?? [];
list.push(resource);
map.set(key, list);
}
return map;
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
imports: [IamModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}
@@ -0,0 +1,165 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
export type CatalogViewer = {
/** C 端用户手机号;无则无法看到白名单商品 */
phone?: string | null;
/** 仅总部代下单等运营场景跳过白名单;合伙人端必须遵守白名单 */
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
async listCities() {
const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: {
partnerAccounts: {
where: { isPrimary: 1, bindingStatus: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
select: { id: true, companyName: true, scopeType: true },
},
},
orderBy: { name: 'asc' },
});
return serializeBigInt(
cities.map((city) => ({
...city,
partnerBindingCount: city.partnerAccounts.length,
partnerBindings: city.partnerAccounts.map((bp) => ({
partnerAccountId: bp.id.toString(),
partnerId: bp.id.toString(),
companyName: bp.companyName,
scopeType: bp.scopeType,
})),
partnerAccounts: undefined,
})),
);
}
async listProducts(aromaType?: string, cityCode?: string, viewer?: CatalogViewer) {
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({
where: { code: cityCode, status: 'ACTIVE' },
});
if (!city) throw new BadRequestException('该城市暂未开城');
}
const products = await this.prisma.commonProductItem.findMany({
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
orderBy: { sortOrder: 'asc' },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
const productIds = visible.map((p) => p.id);
const resources = productIds.length
? await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: { in: productIds },
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
})
: [];
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt(
visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
return {
...rest,
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
...media,
};
}),
);
}
async getProduct(id: bigint, viewer?: CatalogViewer) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!product) return null;
if (!this.isVisibleToViewer(product, viewer)) {
return null;
}
const resources = await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: id,
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
});
const media = mapProductMedia(product, resources);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
return serializeBigInt({
...rest,
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
...media,
});
}
/** 下单前校验:白名单商品仅白名单手机号可买 */
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId },
include: { visibilityPhones: { select: { phone: true } } },
});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
if (!this.isVisibleToViewer(product, { phone: viewerPhone })) {
throw new BadRequestException('该商品暂不对当前账号开放');
}
return product;
}
async resolveUserPhone(userId: bigint): Promise<string | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { phone: true },
});
return user?.phone ?? null;
}
isVisibleToViewer(
product: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
viewer?: CatalogViewer,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
if (!phone) return false;
return product.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PartnerCityService } from './partner-city.service';
import { CityWarehouseService } from './city-warehouse.service';
@Module({
providers: [PartnerCityService, CityWarehouseService],
exports: [PartnerCityService, CityWarehouseService],
})
export class CityScopeModule {}
@@ -0,0 +1,290 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
Prisma,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
} from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from './partner-city.service';
import type { AdminCityWarehousesQueryDto } from '../ops/dto/admin-query.dto';
export type CreateCityWarehouseInput = {
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId?: bigint;
status?: WarehouseStatus;
fulfillmentMode?: WarehouseFulfillmentMode;
fulfillmentProviderId?: bigint;
manualCarrierLabel?: string;
manualQueryUrlTemplate?: string;
lng?: number;
lat?: number;
};
export type UpdateCityWarehouseInput = Partial<
Omit<
CreateCityWarehouseInput,
'partnerAccountId' | 'fulfillmentProviderId' | 'manualCarrierLabel' | 'manualQueryUrlTemplate' | 'lng' | 'lat'
>
> & {
partnerAccountId?: bigint | null;
fulfillmentProviderId?: bigint | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
};
@Injectable()
export class CityWarehouseService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listByCity(cityId: bigint) {
const rows = await this.prisma.cityWarehouse.findMany({
where: { cityId },
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async listAll(query: AdminCityWarehousesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CityWarehouseWhereInput = {};
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.name) where.name = { contains: query.name };
if (query.managerType) where.managerType = query.managerType as Prisma.EnumWarehouseManagerTypeFilter['equals'];
if (query.status) where.status = query.status as Prisma.EnumWarehouseStatusFilter['equals'];
const [rows, total] = await Promise.all([
this.prisma.cityWarehouse.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
city: { select: { id: true, name: true, code: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.cityWarehouse.count({ where }),
]);
return serializeBigInt({
items: rows.map((row) => ({
...this.toDto(row),
cityName: row.city.name,
cityCode: row.city.code,
})),
total,
page,
pageSize,
});
}
async create(cityId: bigint, input: CreateCityWarehouseInput) {
await this.assertCityExists(cityId);
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
await this.validateFulfillment(input.fulfillmentMode, input.fulfillmentProviderId);
const row = await this.prisma.cityWarehouse.create({
data: {
cityId,
name: input.name.trim(),
address: input.address.trim(),
contactName: input.contactName.trim(),
contactPhone: input.contactPhone.trim(),
managerType: input.managerType,
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
status: input.status ?? 'ACTIVE',
fulfillmentMode: input.fulfillmentMode ?? 'MANUAL',
fulfillmentProviderId:
input.fulfillmentMode === 'API_AUTO' ? input.fulfillmentProviderId : null,
manualCarrierLabel: input.manualCarrierLabel?.trim() || null,
manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null,
lng: input.lng != null ? input.lng : null,
lat: input.lat != null ? input.lat : null,
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
return this.toDto(row);
}
async update(id: bigint, input: UpdateCityWarehouseInput) {
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓库不存在');
const managerType = input.managerType ?? (current.managerType as WarehouseManagerType);
const partnerAccountId =
managerType === 'PARTNER'
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
: null;
const fulfillmentMode = input.fulfillmentMode ?? current.fulfillmentMode;
const fulfillmentProviderId =
fulfillmentMode === 'API_AUTO'
? input.fulfillmentProviderId !== undefined
? input.fulfillmentProviderId
: current.fulfillmentProviderId
: null;
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
await this.validateFulfillment(fulfillmentMode, fulfillmentProviderId ?? undefined);
const row = await this.prisma.cityWarehouse.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.address !== undefined ? { address: input.address.trim() } : {}),
...(input.contactName !== undefined ? { contactName: input.contactName.trim() } : {}),
...(input.contactPhone !== undefined ? { contactPhone: input.contactPhone.trim() } : {}),
...(input.managerType !== undefined ? { managerType: input.managerType } : {}),
...(input.managerType !== undefined || input.partnerAccountId !== undefined
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
: {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.fulfillmentMode !== undefined ? { fulfillmentMode } : {}),
...(input.fulfillmentMode !== undefined || input.fulfillmentProviderId !== undefined
? { fulfillmentProviderId }
: {}),
...(input.manualCarrierLabel !== undefined
? { manualCarrierLabel: input.manualCarrierLabel?.trim() || null }
: {}),
...(input.manualQueryUrlTemplate !== undefined
? { manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null }
: {}),
...(input.lng !== undefined ? { lng: input.lng } : {}),
...(input.lat !== undefined ? { lat: input.lat } : {}),
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
return this.toDto(row);
}
async remove(id: bigint) {
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓库不存在');
await this.prisma.partnerAccount.updateMany({
where: { managedWarehouseId: id },
data: { managedWarehouseId: null },
});
await this.prisma.cityWarehouse.delete({ where: { id } });
return { ok: true, id: id.toString() };
}
private async syncManagedWarehouse(
warehouseId: bigint,
managerType: WarehouseManagerType,
partnerAccountId: bigint | null,
) {
await this.prisma.partnerAccount.updateMany({
where: { managedWarehouseId: warehouseId },
data: { managedWarehouseId: null },
});
if (managerType === 'PARTNER' && partnerAccountId) {
await this.prisma.partnerAccount.updateMany({
where: { id: partnerAccountId, managedWarehouseId: { not: warehouseId } },
data: { managedWarehouseId: null },
});
await this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data: { managedWarehouseId: warehouseId },
});
}
}
private validateManager(
managerType: WarehouseManagerType,
partnerAccountId: bigint | undefined,
cityId: bigint,
) {
if (managerType === 'PARTNER') {
if (!partnerAccountId) throw new BadRequestException('合伙人管仓须指定合伙人');
return this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
}
}
private async validateFulfillment(
mode?: WarehouseFulfillmentMode,
providerId?: bigint,
) {
if (mode === 'API_AUTO') {
if (!providerId) throw new BadRequestException('API 自动推单须选择仓配承运商');
const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
throw new BadRequestException('所选仓配承运商不可用');
}
}
}
private async assertCityExists(cityId: bigint) {
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
}
private toDto(row: {
id: bigint;
cityId: bigint;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: string;
partnerAccountId: bigint | null;
status: string;
fulfillmentMode: string;
fulfillmentProviderId: bigint | null;
manualCarrierLabel: string | null;
manualQueryUrlTemplate: string | null;
lng: Prisma.Decimal | null;
lat: Prisma.Decimal | null;
createdAt: Date;
updatedAt: Date;
partnerAccount?: { id: bigint; companyName: string | null } | null;
fulfillmentProvider?: { id: bigint; code: string; name: string } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
cityId: row.cityId.toString(),
name: row.name,
address: row.address,
contactName: row.contactName,
contactPhone: row.contactPhone,
managerType: row.managerType,
partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerCompanyName: row.partnerAccount?.companyName ?? null,
status: row.status,
fulfillmentMode: row.fulfillmentMode,
fulfillmentProviderId: row.fulfillmentProviderId?.toString() ?? null,
fulfillmentProviderName: row.fulfillmentProvider?.name ?? null,
fulfillmentProviderCode: row.fulfillmentProvider?.code ?? null,
manualCarrierLabel: row.manualCarrierLabel,
manualQueryUrlTemplate: row.manualQueryUrlTemplate,
lng: row.lng != null ? Number(row.lng) : null,
lat: row.lat != null ? Number(row.lat) : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -0,0 +1,200 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveOrderCityPartner, validatePartnerCityBinding } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
const PRIMARY_WHERE = { isPrimary: 1 } as const;
@Injectable()
export class PartnerCityService {
constructor(private readonly prisma: PrismaService) {}
async listByCity(cityId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId },
include: { city: { select: { id: true, code: true, name: true } } },
orderBy: [{ scopeType: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async listCityIdsForPartnerAccount(partnerAccountId: bigint): Promise<bigint[]> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return [];
return [primary.cityId];
}
async assertPartnerAccountBoundToCity(partnerAccountId: bigint, cityId: bigint) {
const row = await this.prisma.partnerAccount.findFirst({
where: {
id: partnerAccountId,
...PRIMARY_WHERE,
cityId,
bindingStatus: 'ACTIVE',
},
});
if (!row) {
throw new BadRequestException('合伙人未绑定该开城城市');
}
return row;
}
/** @deprecated */
async assertPartnerBoundToCity(partnerAccountId: bigint, cityId: bigint) {
return this.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
}
async resolveForOrder(cityId: bigint, receiverDistrict?: string | null) {
const bindings = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId, bindingStatus: 'ACTIVE' },
});
const ref = resolveOrderCityPartner(
bindings.map((b) => ({
id: b.id.toString(),
partnerAccountId: b.id.toString(),
scopeType: b.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(b.districtCodes),
orderCommissionRate: Number(b.orderCommissionRate ?? 0),
redeemCommissionRate: Number(b.redeemCommissionRate ?? 0.03),
bindingStatus: b.bindingStatus as CityPartnerStatus,
})),
receiverDistrict,
);
if (!ref) return null;
return {
partnerAccountId: BigInt(ref.partnerAccountId),
orderCommissionRate: ref.orderCommissionRate,
redeemCommissionRate: ref.redeemCommissionRate,
};
}
async validatePrimaryBinding(
cityId: bigint,
input: {
partnerAccountId?: string;
scopeType: CityPartnerScopeType;
districtCodes?: string[];
},
excludeId?: bigint,
) {
const existing = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId, ...(excludeId ? { NOT: { id: excludeId } } : {}) },
});
const validation = validatePartnerCityBinding(
existing.map((r) => ({
id: r.id.toString(),
partnerAccountId: r.id.toString(),
scopeType: r.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(r.districtCodes),
companyName: r.companyName,
})),
{
partnerAccountId: input.partnerAccountId ?? 'new',
scopeType: input.scopeType,
districtCodes: input.districtCodes,
},
excludeId?.toString(),
);
if (!validation.ok) throw new BadRequestException(validation.message);
}
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
// 未配置管仓:不推送/不展示酒订单(同城无仓由总部履约)
if (warehouseIds.length === 0) {
return { id: -1n };
}
return { fulfillmentWarehouseId: { in: warehouseIds } };
}
async hasManagedWarehouse(partnerAccountId: bigint): Promise<boolean> {
const ids = await this.resolveManagedWarehouseIds(partnerAccountId);
return ids.length > 0;
}
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
async resolveManagedWarehouseIds(partnerAccountId: bigint): Promise<bigint[]> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
const ids = new Set<bigint>();
if (primary.managedWarehouseId) {
ids.add(primary.managedWarehouseId);
}
const managed = await this.prisma.cityWarehouse.findMany({
where: {
status: 'ACTIVE',
OR: [
{ partnerAccountId: primary.id },
...(primary.managedWarehouseId ? [{ id: primary.managedWarehouseId }] : []),
],
},
select: { id: true },
});
for (const row of managed) ids.add(row.id);
return [...ids];
}
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
return { id: primary.cityId };
}
async resolvePrimaryAccount(accountId: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) {
throw new BadRequestException('子账号缺少主账号');
}
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
parseDistrictCodes(value: Prisma.JsonValue | null): string[] | null {
if (!value || !Array.isArray(value)) return null;
return value.map((v) => String(v));
}
toDto(row: {
id: bigint;
cityId: bigint | null;
companyName: string | null;
phone: string;
name: string;
scopeType: string | null;
districtCodes: Prisma.JsonValue | null;
orderCommissionRate: Prisma.Decimal | null;
redeemCommissionRate: Prisma.Decimal | null;
bindingStatus: string | null;
managedWarehouseId: bigint | null;
status: string;
createdAt: Date;
updatedAt: Date;
city?: { id: bigint; code: string; name: string } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
cityId: row.cityId?.toString() ?? null,
cityName: row.city?.name ?? null,
cityCode: row.city?.code ?? null,
companyName: row.companyName,
phone: row.phone,
name: row.name,
scopeType: row.scopeType,
districtCodes: this.parseDistrictCodes(row.districtCodes),
orderCommissionRate: Number(row.orderCommissionRate ?? 0),
redeemCommissionRate: Number(row.redeemCommissionRate ?? 0.03),
bindingStatus: row.bindingStatus,
managedWarehouseId: row.managedWarehouseId?.toString() ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -0,0 +1,28 @@
import { Controller, Get } from '@nestjs/common';
import { parseMiniHomeBanners } from '@dukang/shared-types';
import { SystemConfigService } from '../../common/system-config/system-config.service';
@Controller('common')
export class ClientConfigController {
constructor(private readonly systemConfig: SystemConfigService) {}
@Get('client-config')
clientConfig() {
const cfg = this.systemConfig.getAppConfig();
const env = this.systemConfig.getMergedEnv();
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
return {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
mockSms: cfg.mockSms,
mockWechat: cfg.mockWechat,
wxAuthorize: cfg.wxAuthorize,
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
tencentLbsKey: cfg.tencentLbsKey || undefined,
miniHome: {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
footerUrl: footer || null,
},
};
}
}
@@ -0,0 +1,25 @@
import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { ClientErrorService } from './client-error.service';
import { ReportClientErrorDto } from './dto/client-error.dto';
@Controller('common')
export class ClientErrorController {
constructor(private readonly clientErrors: ClientErrorService) {}
/**
* 客户端报错上报(可匿名)。
* fatal/error → Nest 日志 + 落库 + 企微;warn → 仅日志/落库。
*/
@Post('client-errors')
@UseGuards(OptionalJwtAuthGuard)
report(
@Body() dto: ReportClientErrorDto,
@CurrentUser() user: AuthUser | undefined,
@Headers('x-client-app') clientApp?: string,
) {
return this.clientErrors.report(dto, user, clientApp);
}
}
@@ -0,0 +1,164 @@
import { Injectable, Logger } from '@nestjs/common';
import type { ClientApp, Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { AlertService } from '../../common/alert/alert.service';
import type { AlertLevel } from '../../common/alert/alert.constants';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import type { ReportClientErrorDto } from './dto/client-error.dto';
const WECOM_LEVELS = new Set(['fatal', 'error']);
@Injectable()
export class ClientErrorService {
private readonly logger = new Logger(ClientErrorService.name);
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
const clientApp = (dto.clientApp || headerClientApp || user?.clientApp || 'UNKNOWN').slice(0, 32);
const message = dto.message.trim().slice(0, 1000);
const stack = dto.stack?.trim().slice(0, 4000);
const pagePath = dto.pagePath?.trim().slice(0, 128);
const fingerprint = `${dto.level}|${dto.category}|${message.slice(0, 120)}`;
const anonymous = user?.actorId == null;
const skipWecom = shouldSkipWecomClientErrorAlert({
message,
pagePath,
clientApp,
anonymous,
category: dto.category,
});
const logLine = {
level: dto.level,
category: dto.category,
message,
pagePath,
clientApp,
actorType: user?.actorType,
actorId: user?.actorId != null ? String(user.actorId) : undefined,
stack: stack?.slice(0, 800),
extra: dto.extra,
skipWecom,
};
if (dto.level === 'fatal') {
this.logger.error(`[client_error] ${JSON.stringify(logLine)}`);
} else if (dto.level === 'error') {
this.logger.error(`[client_error] ${JSON.stringify(logLine)}`);
} else {
this.logger.warn(`[client_error] ${JSON.stringify(logLine)}`);
}
// 落库便于 HQ 排查(匿名也可写,userId 为空)
try {
await this.prisma.logUserAnalytics.create({
data: {
userId:
user?.actorType === 'USER' && user.actorId != null ? user.actorId : null,
eventName: 'client_error',
clientApp: isClientApp(clientApp) ? clientApp : null,
pagePath: pagePath || null,
extraJson: {
level: dto.level,
category: dto.category,
message,
stack: stack || null,
actorType: user?.actorType ?? null,
actorId: user?.actorId != null ? String(user.actorId) : null,
storeId: user?.storeId != null ? String(user.storeId) : null,
skipWecom,
...(dto.extra && typeof dto.extra === 'object' ? { clientExtra: dto.extra } : {}),
} as Prisma.InputJsonValue,
},
});
} catch (e) {
this.logger.warn(
`client_error persist failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (WECOM_LEVELS.has(dto.level) && !skipWecom) {
const alertLevel: AlertLevel = dto.level === 'fatal' ? 'P0' : 'P1';
this.alert.notify({
level: alertLevel,
category: 'client_error',
title: `客户端报错 [${dto.level}/${dto.category}]`,
detail: [
`端:${clientApp}`,
pagePath ? `页面:${pagePath}` : null,
user?.actorId != null
? `用户:${user.actorType}:${String(user.actorId)}`
: '用户:匿名',
`消息:${message}`,
stack ? `堆栈:${stack.slice(0, 600)}` : null,
]
.filter(Boolean)
.join('\n'),
dedupeKey: `client_error|${fingerprint}`,
dedupeTtlSec: 600,
});
} else if (skipWecom && WECOM_LEVELS.has(dto.level)) {
this.logger.log(
`[client_error] skip WeCom alert (likely mini-program audit noise): ${message.slice(0, 160)}`,
);
}
return { ok: true };
}
}
/**
* 过滤企微推送:微信小程序审核机 / 自动化探测常见噪声。
* 仍落库与写服务端日志,仅跳过 webhook。
*
* 典型特征(与本次协议页报错一致):
* - 匿名 USER_MINI
* - navigateTo/redirectTo 等 page … is not found(常带 .htmlTaro H5 路径形态)
*/
export function shouldSkipWecomClientErrorAlert(input: {
message: string;
pagePath?: string | null;
clientApp: string;
anonymous: boolean;
category?: string;
}): boolean {
const msg = input.message || '';
const app = input.clientApp || '';
const isMini = app === 'USER_MINI' || app === 'PARTNER_MINI' || app === 'HQ_MINI';
// 路由页不存在:审核机点协议/隐私链接触发最常见
const isNavPageMissing =
/(navigateTo|redirectTo|reLaunch|switchTab):fail/i.test(msg) &&
/is not found/i.test(msg);
// Taro 把路径拼成 *.html 的形态,几乎不可能是真·原生页路径
const isTaroHtmlPagePath =
/\.html(\b|"|')/i.test(msg) && /is not found|page /i.test(msg);
if (isMini && input.anonymous && (isNavPageMissing || isTaroHtmlPagePath)) {
return true;
}
// 即使带登录态,纯 *.html not found 也视为框架/探测噪声
if (isMini && isTaroHtmlPagePath) {
return true;
}
return false;
}
function isClientApp(v: string): v is ClientApp {
return [
'USER_MINI',
'USER_H5',
'PARTNER_MINI',
'PARTNER_H5',
'HQ_MINI',
'HQ_WEB',
'SHOP_H5',
].includes(v);
}
@@ -0,0 +1,46 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { SystemConfigModule } from '../../common/system-config/system-config.module';
import { DevPlanModule } from '../dev-plan/dev-plan.module';
import { ResourceService } from './resource.service';
import { EventService } from './event.service';
import { TicketService } from './ticket.service';
import { SupportTicketService } from './support-ticket.service';
import { ThirdPartyLogService } from './third-party-log.service';
import { ResourceController } from './resource.controller';
import { EventController } from './event.controller';
import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller';
import { WechatController } from './wechat.controller';
import { ClientConfigController } from './client-config.controller';
import { LbsController } from './lbs.controller';
import { WechatLocationService } from './wechat-location.service';
import { ClientErrorService } from './client-error.service';
import { ClientErrorController } from './client-error.controller';
@Module({
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule), DevPlanModule],
controllers: [
ResourceController,
EventController,
TicketController,
ThirdPartyLogController,
WechatController,
ClientConfigController,
LbsController,
ClientErrorController,
],
providers: [
ResourceService,
EventService,
TicketService,
SupportTicketService,
ThirdPartyLogService,
WechatLocationService,
ClientErrorService,
],
exports: [ResourceService, EventService, TicketService, SupportTicketService],
})
export class CommonModule {}
@@ -0,0 +1,48 @@
import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
/** 客户端报错严重级别 */
export const CLIENT_ERROR_LEVELS = ['fatal', 'error', 'warn'] as const;
export type ClientErrorLevel = (typeof CLIENT_ERROR_LEVELS)[number];
/** 客户端报错类别 */
export const CLIENT_ERROR_CATEGORIES = [
'js_error',
'unhandled_rejection',
'api_error',
'network',
'render',
'bridge',
'other',
] as const;
export type ClientErrorCategory = (typeof CLIENT_ERROR_CATEGORIES)[number];
export class ReportClientErrorDto {
@IsIn(CLIENT_ERROR_LEVELS)
level!: ClientErrorLevel;
@IsIn(CLIENT_ERROR_CATEGORIES)
category!: ClientErrorCategory;
@IsString()
@MaxLength(1000)
message!: string;
@IsOptional()
@IsString()
@MaxLength(4000)
stack?: string;
@IsOptional()
@IsString()
@MaxLength(128)
pagePath?: string;
@IsOptional()
@IsString()
@MaxLength(64)
clientApp?: string;
@IsOptional()
@IsObject()
extra?: Record<string, unknown>;
}
@@ -0,0 +1,208 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UploadTokenDto {
@IsString()
@IsNotEmpty()
bizType: string;
@IsString()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType: string;
@IsString()
@IsNotEmpty()
fileName: string;
}
export class UploadFileDto {
@IsString()
@IsNotEmpty()
bizType: string;
@IsString()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType: string;
}
export class RegisterResourceDto {
@IsString()
@IsNotEmpty()
ownerType: string;
@IsString()
@IsNotEmpty()
ownerId: string;
@IsString()
@IsNotEmpty()
bizType: string;
@IsString()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType: string;
@IsString()
@IsNotEmpty()
ossKey: string;
@IsString()
@IsNotEmpty()
url: string;
@IsOptional()
@IsString()
ossBucket?: string;
@IsOptional()
@IsString()
fileName?: string;
@IsOptional()
sortOrder?: number;
}
export class UpdateResourceDto {
@IsOptional()
@IsString()
url?: string;
@IsOptional()
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
mediaType?: string;
@IsOptional()
sortOrder?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DELETED'])
status?: string;
}
export class CreateEventDto {
@IsString()
@IsNotEmpty()
eventType: string;
@IsString()
@IsNotEmpty()
refType: string;
@IsString()
@IsNotEmpty()
refId: string;
@IsOptional()
@IsString()
actorType?: string;
@IsOptional()
@IsString()
actorId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
param1?: string;
@IsOptional()
@IsString()
param1Desc?: string;
@IsOptional()
@IsString()
param2?: string;
@IsOptional()
@IsString()
param2Desc?: string;
@IsOptional()
@IsString()
param3?: string;
@IsOptional()
@IsString()
param3Desc?: string;
@IsOptional()
amount1?: number;
@IsOptional()
amount2?: number;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
extraJson?: Record<string, unknown>;
}
export class CreateTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
ticketType: string;
@IsString()
@IsNotEmpty()
refType: string;
@IsString()
@IsNotEmpty()
refId: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
param1?: string;
@IsOptional()
@IsString()
param1Desc?: string;
@IsOptional()
extraJson?: Record<string, unknown>;
}
export class AdminCreateTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
ticketType: string;
@IsString()
@IsNotEmpty()
orderNo: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
evidenceUrls?: string[];
}
export class UpdateTicketStatusDto {
@IsString()
@IsNotEmpty()
status: string;
@IsOptional()
@IsString()
remark?: string;
}
export class AssignTicketDto {
@IsString()
@IsIn(['HQ', 'PARTNER', 'SYSTEM'])
operatorType: string;
@IsString()
@IsNotEmpty()
operatorId: string;
}
@@ -0,0 +1,93 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
export class ResourceListQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
ownerType?: string;
@IsOptional()
@IsString()
ownerId?: string;
@IsOptional()
@IsString()
bizType?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DELETED'])
status?: string;
}
export class EventListQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
refType?: string;
@IsOptional()
@IsString()
refId?: string;
@IsOptional()
@IsString()
eventType?: string;
}
export class EventTimelineQueryDto {
@IsString()
refType: string;
@IsString()
refId: string;
}
export class TicketListQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
ticketType?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
refType?: string;
@IsOptional()
@IsString()
refId?: string;
}
export class ThirdPartyLogQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsString()
scene?: string;
@IsOptional()
@IsString()
refType?: string;
@IsOptional()
@IsString()
refId?: string;
}
@@ -0,0 +1,58 @@
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
export class SupportTicketListQueryDto {
@IsOptional()
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
ticketType?: string;
@IsOptional()
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
status?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}
export class CreateSupportTicketDto {
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
ticketType: string;
@IsString()
@IsNotEmpty()
@MaxLength(128)
title: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
@MaxLength(512)
remark?: string;
}
export class RejectSupportTicketDto {
@IsString()
@IsNotEmpty()
@MaxLength(512)
rejectReason: string;
}
export class SupportTicketRemarkDto {
@IsOptional()
@IsString()
@MaxLength(512)
remark?: string;
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { EventService } from './event.service';
import { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
import { CreateEventDto } from './dto/common-mutate.dto';
@Controller('common/events')
@UseGuards(JwtAuthGuard)
export class EventController {
constructor(private readonly service: EventService) {}
@Post()
create(@Body() dto: CreateEventDto) {
return this.service.create(dto);
}
@Get()
list(@Query() query: EventListQueryDto) {
return this.service.list(query);
}
@Get('timeline')
timeline(@Query() query: EventTimelineQueryDto) {
return this.service.timeline(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ActorType, EventType, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
import type { CreateEventDto } from './dto/common-mutate.dto';
@Injectable()
export class EventService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateEventDto) {
const event = await this.prisma.commonEvent.create({
data: {
eventType: dto.eventType as EventType,
refType: dto.refType,
refId: BigInt(dto.refId),
actorType: dto.actorType as ActorType | undefined,
actorId: dto.actorId ? BigInt(dto.actorId) : undefined,
status: dto.status,
param1: dto.param1,
param1Desc: dto.param1Desc,
param2: dto.param2,
param2Desc: dto.param2Desc,
param3: dto.param3,
param3Desc: dto.param3Desc,
amount1: dto.amount1,
amount2: dto.amount2,
remark: dto.remark,
extraJson: dto.extraJson as Prisma.InputJsonValue | undefined,
},
});
return serializeBigInt(event);
}
async list(query: EventListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonEventWhereInput = {};
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
if (query.eventType) where.eventType = query.eventType as Prisma.EnumEventTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonEvent.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async timeline(query: EventTimelineQueryDto) {
const items = await this.prisma.commonEvent.findMany({
where: { refType: query.refType, refId: BigInt(query.refId) },
orderBy: { createdAt: 'asc' },
take: 200,
});
return serializeBigInt(items);
}
async detail(id: bigint) {
const event = await this.prisma.commonEvent.findUnique({ where: { id } });
if (!event) throw new NotFoundException('事件不存在');
return serializeBigInt(event);
}
}
@@ -0,0 +1,91 @@
import {
BadRequestException,
Controller,
Get,
Query,
ServiceUnavailableException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
function parseCoord(raw: string | undefined, label: string): number | undefined {
if (raw == null || raw === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) throw new BadRequestException(`${label}无效`);
return n;
}
@Controller('common/lbs')
@UseGuards(JwtAuthGuard)
export class LbsController {
constructor(private readonly tencentLbs: TencentLbsProvider) {}
@Get('suggest')
async suggest(
@Query('keyword') keyword?: string,
@Query('region') region?: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const q = (keyword ?? '').trim();
if (!q) return { items: [] };
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
const result = await this.tencentLbs.suggestPlaces(q, {
region: region?.trim() || undefined,
latitude,
longitude,
});
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('nearby')
async nearby(@Query('lat') lat?: string, @Query('lng') lng?: string, @Query('radius') radius?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const r = radius != null && radius !== '' ? Number(radius) : 1000;
const result = await this.tencentLbs.exploreNearby(latitude, longitude, Number.isFinite(r) ? r : 1000);
if (result.error && !result.items.length) {
throw new BadRequestException(result.error);
}
return { items: result.items };
}
@Get('reverse')
async reverse(@Query('lat') lat?: string, @Query('lng') lng?: string) {
if (!this.tencentLbs.isEnabled()) {
throw new ServiceUnavailableException('未配置腾讯位置服务 KeyTENCENT_LBS_KEY');
}
const latitude = parseCoord(lat, '纬度');
const longitude = parseCoord(lng, '经度');
if (latitude == null || longitude == null) {
throw new BadRequestException('请提供 lat、lng');
}
const result = await this.tencentLbs.reverseGeocodeDetail(latitude, longitude);
if (!result.item) {
throw new BadRequestException(result.error || '逆地理编码失败');
}
return {
latitude: result.item.latitude,
longitude: result.item.longitude,
address: result.item.address,
name: result.item.name,
province: result.item.province,
city: result.item.city,
district: result.item.district,
};
}
}
@@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard, type AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ResourceService, type OssUploadActor } from './resource.service';
import { ResourceListQueryDto } from './dto/common-query.dto';
import { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
function resolveUploadActor(user?: AuthUser): OssUploadActor | undefined {
if (!user) return undefined;
return {
refType: user.actorType,
refId: user.actorId,
clientApp: user.clientApp,
};
}
@Controller('common/resources')
@UseGuards(JwtAuthGuard)
export class ResourceController {
constructor(private readonly service: ResourceService) {}
@Post('upload-token')
uploadToken(@CurrentUser() user: AuthUser, @Body() dto: UploadTokenDto) {
return this.service.getUploadToken(dto, resolveUploadActor(user));
}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES) },
}),
)
upload(
@CurrentUser() user: AuthUser,
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadFileDto,
) {
return this.service.uploadFile(file, dto, resolveUploadActor(user));
}
@Post()
register(@Body() dto: RegisterResourceDto) {
return this.service.register(dto);
}
@Get()
list(@Query() query: ResourceListQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,260 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util';
import type { ResourceListQueryDto } from './dto/common-query.dto';
import type { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
export type OssUploadActor = OssActorRef & {
clientApp?: string;
};
@Injectable()
export class ResourceService {
constructor(
private readonly prisma: PrismaService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
) {}
getUploadToken(dto: UploadTokenDto, actor?: OssUploadActor) {
try {
const result = this.oss.getUploadToken(dto);
void logOssUpload(this.prisma, {
scene: 'UPLOAD_TOKEN',
actorRef: actor,
requestBody: {
bizType: dto.bizType,
mediaType: dto.mediaType,
fileName: dto.fileName,
clientApp: actor?.clientApp,
},
responseBody: {
bucket: result.bucket,
ossKey: result.ossKey,
url: result.url,
mock: result.mock ?? false,
},
externalNo: result.ossKey,
status: 'SUCCESS',
});
return result;
} catch (err) {
void logOssUpload(this.prisma, {
scene: 'UPLOAD_TOKEN',
actorRef: actor,
requestBody: {
bizType: dto.bizType,
mediaType: dto.mediaType,
fileName: dto.fileName,
clientApp: actor?.clientApp,
},
status: 'FAILED',
errorMessage: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
async uploadFile(
file: Express.Multer.File | undefined,
dto: UploadFileDto,
actor?: OssUploadActor,
) {
const baseRequest = {
bizType: dto.bizType,
mediaType: dto.mediaType,
clientApp: actor?.clientApp,
};
if (!file) {
await logOssUpload(this.prisma, {
scene: 'UPLOAD_PUT_OBJECT',
actorRef: actor,
requestBody: baseRequest,
status: 'FAILED',
errorMessage: '请选择要上传的文件',
});
throw new BadRequestException('请选择要上传的文件');
}
const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
const requestBody = {
...baseRequest,
fileName: file.originalname || 'upload.bin',
fileSize: file.size,
mimeType: file.mimetype,
};
if (file.size > maxUploadBytes) {
const message = `文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`;
await logOssUpload(this.prisma, {
scene: 'UPLOAD_PUT_OBJECT',
actorRef: actor,
requestBody,
status: 'FAILED',
errorMessage: message,
});
throw new BadRequestException(message);
}
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
throw new BadRequestException('头像仅支持图片文件');
}
try {
const result = await this.oss.putObject({
bizType: dto.bizType,
mediaType: dto.mediaType,
fileName: file.originalname || 'upload.bin',
buffer: file.buffer,
mimeType: file.mimetype,
});
await logOssUpload(this.prisma, {
scene: 'UPLOAD_PUT_OBJECT',
actorRef: actor,
requestBody,
responseBody: {
bucket: result.bucket,
region: result.region,
ossKey: result.ossKey,
url: result.url,
mock: result.mock ?? false,
},
externalNo: result.ossKey,
status: 'SUCCESS',
});
if (actor?.refType === 'USER' && dto.bizType === 'AVATAR' && dto.mediaType === 'IMAGE') {
const resource = await this.prisma.commonResource.create({
data: {
ownerType: 'USER',
ownerId: actor.refId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
ossBucket: result.bucket,
ossKey: result.ossKey,
url: result.url,
fileName: file.originalname || 'avatar',
fileSize: BigInt(file.size),
mimeType: file.mimetype,
status: 'ACTIVE',
},
});
return serializeBigInt({ ...result, resourceId: resource.id });
}
return result;
} catch (err) {
await logOssUpload(this.prisma, {
scene: 'UPLOAD_PUT_OBJECT',
actorRef: actor,
requestBody,
status: 'FAILED',
errorMessage: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
async getOwnedActiveAvatar(resourceId: bigint, userId: bigint) {
const resource = await this.prisma.commonResource.findFirst({
where: {
id: resourceId,
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
status: 'ACTIVE',
},
});
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
return resource;
}
async getOwnedActiveAvatarByUrl(url: string, userId: bigint) {
const resource = await this.prisma.commonResource.findFirst({
where: {
url,
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
status: 'ACTIVE',
},
orderBy: { createdAt: 'desc' },
});
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
return resource;
}
async register(dto: RegisterResourceDto) {
const resource = await this.prisma.commonResource.create({
data: {
ownerType: dto.ownerType as ResourceOwnerType,
ownerId: BigInt(dto.ownerId),
bizType: dto.bizType as ResourceBizType,
mediaType: dto.mediaType as ResourceMediaType,
ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang',
ossKey: dto.ossKey,
url: dto.url || this.oss.buildPublicUrl(dto.ossKey),
fileName: dto.fileName,
sortOrder: dto.sortOrder ?? 0,
},
});
return serializeBigInt(resource);
}
async list(query: ResourceListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonResourceWhereInput = {
status: (query.status ?? 'ACTIVE') as Prisma.EnumResourceStatusFilter['equals'],
};
if (query.ownerType) where.ownerType = query.ownerType as Prisma.EnumResourceOwnerTypeFilter['equals'];
if (query.ownerId) where.ownerId = BigInt(query.ownerId);
if (query.bizType) where.bizType = query.bizType as Prisma.EnumResourceBizTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonResource.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const resource = await this.prisma.commonResource.findUnique({ where: { id } });
if (!resource) throw new NotFoundException('资源不存在');
return serializeBigInt(resource);
}
async update(id: bigint, dto: UpdateResourceDto) {
await this.detail(id);
const resource = await this.prisma.commonResource.update({
where: { id },
data: {
...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' | 'FILE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DELETED' } : {}),
},
});
return serializeBigInt(resource);
}
async remove(id: bigint) {
await this.detail(id);
await this.prisma.commonResource.update({
where: { id },
data: { status: 'DELETED' },
});
return { ok: true };
}
}
@@ -0,0 +1,280 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { DevPlanService } from '../dev-plan/dev-plan.service';
import type {
CreateSupportTicketDto,
RejectSupportTicketDto,
SupportTicketListQueryDto,
SupportTicketRemarkDto,
} from './dto/support-ticket.dto';
function generateSupportTicketNo() {
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
@Injectable()
export class SupportTicketService {
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
private readonly devPlan: DevPlanService,
private readonly wecomPush: WecomMessagePushService,
) {}
async create(
dto: CreateSupportTicketDto,
creator: { id: bigint; name: string },
) {
const ticket = await this.prisma.commonSupportTicket.create({
data: {
ticketNo: generateSupportTicketNo(),
ticketType: dto.ticketType as SupportTicketType,
status: 'PENDING_REVIEW',
title: dto.title.trim(),
content: dto.content?.trim() || null,
remark: dto.remark?.trim() || null,
creatorId: creator.id,
creatorName: creator.name,
},
});
this.alert.notify({
level: 'P2',
category: 'ops',
title: '新建技术支持工单',
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n标题 ${ticket.title}\n创建人 ${creator.name}`,
dedupeKey: `support_ticket_create|${ticket.ticketNo}`,
eventKeys: ['alert.ops'],
});
const envLabel = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || 'local').trim();
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
void this.wecomPush
.dispatchMarkdown(
'support_ticket.created',
[
`## 新建技术支持工单`,
`> 环境:<font color="comment">${envLabel}</font>`,
`> 时间:${now}`,
'',
`**工单号**${ticket.ticketNo}`,
`**类型**${ticket.ticketType}`,
`**标题**${ticket.title}`,
`**创建人**${creator.name}`,
ticket.content ? `\n${ticket.content.slice(0, 2000)}` : '',
].join('\n'),
)
.catch(() => {});
return serializeBigInt(ticket);
}
async list(query: SupportTicketListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonSupportTicketWhereInput = {};
if (query.ticketType) {
where.ticketType = query.ticketType as SupportTicketType;
}
if (query.status) {
where.status = query.status as SupportTicketStatus;
}
const [items, total] = await Promise.all([
this.prisma.commonSupportTicket.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonSupportTicket.count({ where }),
]);
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds(items.map((i) => i.id));
const enriched = items.map((ticket) => ({
...ticket,
linkedTasks: (linkedMap.get(String(ticket.id)) ?? []).map((t) => ({
id: t.id,
taskNo: t.taskNo,
content: t.content,
status: t.status,
})),
}));
return serializeBigInt({ items: enriched, total, page, pageSize });
}
async detail(id: bigint) {
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('技术支持工单不存在');
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
return serializeBigInt({
...ticket,
linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({
id: t.id,
taskNo: t.taskNo,
content: t.content,
status: t.status,
})),
});
}
private async getOrThrow(id: bigint) {
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('技术支持工单不存在');
return ticket;
}
/** 最高管理员评审通过 → 进入开发 */
async approve(
id: bigint,
reviewer: { id: bigint; name: string },
dto?: SupportTicketRemarkDto,
) {
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待评审工单可通过评审');
}
const updated = await this.prisma.commonSupportTicket.update({
where: { id },
data: {
status: 'DEVELOPING',
reviewerId: reviewer.id,
reviewerName: reviewer.name,
reviewedAt: new Date(),
remark: dto?.remark?.trim() || ticket.remark,
},
});
return serializeBigInt(updated);
}
/** 最高管理员评审驳回 */
async reject(
id: bigint,
reviewer: { id: bigint; name: string },
dto: RejectSupportTicketDto,
) {
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待评审工单可驳回');
}
const reason = dto.rejectReason.trim();
if (!reason) throw new BadRequestException('请填写驳回理由');
const updated = await this.prisma.commonSupportTicket.update({
where: { id },
data: {
status: 'REJECTED',
rejectReason: reason,
reviewerId: reviewer.id,
reviewerName: reviewer.name,
reviewedAt: new Date(),
completedAt: new Date(),
},
});
return serializeBigInt(updated);
}
/** 统一审批:通过时创建开发计划任务 */
async review(
id: bigint,
reviewer: { id: bigint; name: string },
input: {
decision: 'APPROVE' | 'REJECT';
rejectReason?: string;
note?: string;
tasks?: CreateDevPlanTaskFromTicketInput[];
},
) {
if (input.decision === 'REJECT') {
if (!input.rejectReason?.trim()) throw new BadRequestException('请填写驳回理由');
return this.reject(id, reviewer, { rejectReason: input.rejectReason });
}
if (!input.tasks?.length) throw new BadRequestException('审批通过需至少创建 1 条开发任务');
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待评审工单可审批');
}
await this.devPlan.createTasksFromTicket(id, input.tasks, reviewer.id);
const updated = await this.prisma.commonSupportTicket.update({
where: { id },
data: {
status: 'DEVELOPING',
reviewerId: reviewer.id,
reviewerName: reviewer.name,
reviewedAt: new Date(),
remark: input.note?.trim() || ticket.remark,
},
});
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
return serializeBigInt({
...updated,
linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({
id: t.id,
taskNo: t.taskNo,
content: t.content,
status: t.status,
})),
});
}
/** 批量确认审批 */
async batchReviewConfirm(
reviewer: { id: bigint; name: string },
items: Array<{
ticketId: string;
decision: 'APPROVE' | 'REJECT';
rejectReason?: string;
note?: string;
tasks?: CreateDevPlanTaskFromTicketInput[];
}>,
) {
const results: unknown[] = [];
for (const item of items) {
const result = await this.review(BigInt(item.ticketId), reviewer, item);
results.push(result);
}
return { items: results };
}
/** 开发完成 → 测试 */
async startTesting(id: bigint, dto?: SupportTicketRemarkDto) {
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'DEVELOPING') {
throw new BadRequestException('仅开发中工单可转入测试');
}
const updated = await this.prisma.commonSupportTicket.update({
where: { id },
data: {
status: 'TESTING',
remark: dto?.remark?.trim() || ticket.remark,
},
});
return serializeBigInt(updated);
}
/** 测试通过 */
async pass(id: bigint, dto?: SupportTicketRemarkDto) {
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'TESTING') {
throw new BadRequestException('仅测试中工单可标记通过');
}
const updated = await this.prisma.commonSupportTicket.update({
where: { id },
data: {
status: 'PASSED',
remark: dto?.remark?.trim() || ticket.remark,
completedAt: new Date(),
},
});
return serializeBigInt(updated);
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { ThirdPartyLogService } from './third-party-log.service';
import { ThirdPartyLogQueryDto } from './dto/common-query.dto';
@Controller('common/third-party-logs')
@UseGuards(HqAuthGuard)
export class ThirdPartyLogController {
constructor(private readonly service: ThirdPartyLogService) {}
@Get()
list(@Query() query: ThirdPartyLogQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,37 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { ThirdPartyLogQueryDto } from './dto/common-query.dto';
@Injectable()
export class ThirdPartyLogService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ThirdPartyLogQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.LogThirdPartyWhereInput = {};
if (query.provider) where.provider = query.provider as Prisma.EnumThirdPartyProviderFilter['equals'];
if (query.scene) where.scene = { contains: query.scene };
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
const [items, total] = await Promise.all([
this.prisma.logThirdParty.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logThirdParty.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const log = await this.prisma.logThirdParty.findUnique({ where: { id } });
if (!log) throw new NotFoundException('日志不存在');
return serializeBigInt(log);
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { TicketService } from './ticket.service';
import { TicketListQueryDto } from './dto/common-query.dto';
import { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
@Controller('common/tickets')
@UseGuards(JwtAuthGuard)
export class TicketController {
constructor(private readonly service: TicketService) {}
/** 总部建单;用户售后请走 /trade/orders/:id/after-sale-tickets */
@Post()
@UseGuards(HqAuthGuard)
create(@Body() dto: CreateTicketDto) {
return this.service.create(dto);
}
@Get()
list(@Query() query: TicketListQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Put(':id/status')
@UseGuards(HqAuthGuard)
updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) {
return this.service.updateStatus(BigInt(id), dto);
}
@Put(':id/assign')
@UseGuards(HqAuthGuard)
assign(@Param('id') id: string, @Body() dto: AssignTicketDto) {
return this.service.assign(BigInt(id), dto);
}
}
@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ActorType, TicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import type { TicketListQueryDto } from './dto/common-query.dto';
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
function generateTicketNo() {
return `TK${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
@Injectable()
export class TicketService {
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
async create(dto: CreateTicketDto) {
const ticket = await this.prisma.commonTicket.create({
data: {
ticketNo: generateTicketNo(),
ticketType: dto.ticketType as TicketType,
refType: dto.refType,
refId: BigInt(dto.refId),
remark: dto.remark,
param1: dto.param1,
param1Desc: dto.param1Desc,
extraJson: dto.extraJson ? (dto.extraJson as Prisma.InputJsonValue) : undefined,
},
});
this.alert.notify({
level: 'P2',
category: 'ops',
title: '新建售后工单',
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n关联 ${ticket.refType}:${ticket.refId}\n${dto.remark ?? ''}`,
dedupeKey: `ticket_create|${ticket.ticketNo}`,
});
return serializeBigInt(ticket);
}
async list(query: TicketListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonTicketWhereInput = {};
if (query.ticketType) where.ticketType = query.ticketType as Prisma.EnumTicketTypeFilter['equals'];
if (query.status) where.status = query.status;
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
const [items, total] = await Promise.all([
this.prisma.commonTicket.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonTicket.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('工单不存在');
return serializeBigInt(ticket);
}
async updateStatus(id: bigint, dto: UpdateTicketStatusDto) {
await this.detail(id);
const ticket = await this.prisma.commonTicket.update({
where: { id },
data: {
status: dto.status,
remark: dto.remark,
completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(dto.status) ? new Date() : undefined,
},
});
return serializeBigInt(ticket);
}
async updateExtraJson(id: bigint, extraJson: Record<string, unknown>, status?: string, remark?: string) {
await this.detail(id);
const ticket = await this.prisma.commonTicket.update({
where: { id },
data: {
extraJson: extraJson as Prisma.InputJsonValue,
...(status
? {
status,
completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(status) ? new Date() : undefined,
}
: {}),
...(remark !== undefined ? { remark } : {}),
},
});
return serializeBigInt(ticket);
}
async assign(id: bigint, dto: AssignTicketDto) {
await this.detail(id);
const ticket = await this.prisma.commonTicket.update({
where: { id },
data: {
operatorType: dto.operatorType as ActorType,
operatorId: BigInt(dto.operatorId),
},
});
return serializeBigInt(ticket);
}
}
@@ -0,0 +1,156 @@
import { Injectable } from '@nestjs/common';
import { ClientApp } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import { logWechatAuth, type WechatActorRef } from '../../integrations/wechat/wechat-log.util';
import { AnalyticsService } from '../analytics/analytics.service';
export type ReportWechatLocationInput = {
latitude?: number;
longitude?: number;
sdk: 'jssdk' | 'geolocation';
status: 'success' | 'fail';
errMsg?: string;
clientApp?: string;
userId?: bigint;
};
export type ReportWechatLocationResult = {
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity: boolean;
thirdPartyLogIds: {
location?: string;
geocode?: string;
};
};
function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim();
}
function matchOpenCity(
cities: Array<{ code: string; name: string; province: string }>,
province: string,
city: string,
) {
const cityNorm = normalizeCityName(city);
return cities.find((c) => {
const nameNorm = normalizeCityName(c.name);
if (nameNorm !== cityNorm && c.name !== city && c.name !== `${cityNorm}`) return false;
if (c.province && province && c.province !== province) return false;
return true;
});
}
@Injectable()
export class WechatLocationService {
constructor(
private readonly prisma: PrismaService,
private readonly tencentLbs: TencentLbsProvider,
private readonly analyticsService: AnalyticsService,
) {}
async reportLocation(input: ReportWechatLocationInput): Promise<ReportWechatLocationResult> {
const actorRef: WechatActorRef | undefined = input.userId
? { refType: 'USER', refId: input.userId }
: undefined;
const clientApp = (input.clientApp as ClientApp) || ClientApp.USER_H5;
const thirdPartyLogIds: ReportWechatLocationResult['thirdPartyLogIds'] = {};
const locationLogId = await logWechatAuth(this.prisma, {
scene: 'GET_LOCATION',
requestBody: {
sdk: input.sdk,
status: input.status,
...(input.latitude != null && input.longitude != null
? {
latitude: Number(input.latitude.toFixed(3)),
longitude: Number(input.longitude.toFixed(3)),
}
: {}),
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
},
responseBody: { reported: true },
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
actorRef,
});
thirdPartyLogIds.location = locationLogId.toString();
if (input.status !== 'success' || input.latitude == null || input.longitude == null) {
return { openCity: false, thirdPartyLogIds };
}
const geo = await this.tencentLbs.reverseGeocode(input.latitude, input.longitude, actorRef);
if (geo) {
thirdPartyLogIds.geocode = geo.logId.toString();
}
if (!geo) {
return { openCity: false, thirdPartyLogIds };
}
const openCities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
select: { code: true, name: true, province: true },
});
const matched = matchOpenCity(openCities, geo.province, geo.city);
const result: ReportWechatLocationResult = {
province: geo.province,
city: geo.city,
district: geo.district,
cityCode: matched?.code,
cityName: matched?.name ?? `${geo.city}`,
openCity: !!matched,
thirdPartyLogIds,
};
if (input.userId) {
const mapLogId = geo.logId;
this.analyticsService.trackOneSafe(input.userId, clientApp, {
eventName: 'wechat_location',
refType: 'THIRD_PARTY_LOG',
refId: mapLogId,
extraJson: {
sdk: input.sdk,
province: geo.province,
city: geo.city,
district: geo.district,
openCity: !!matched,
cityCode: matched?.code,
},
});
}
return result;
}
async reportChooseImage(input: {
status: 'success' | 'fail';
errMsg?: string;
sourceType?: string;
stage?: string;
pageUrl?: string;
actorRef?: WechatActorRef;
}) {
const logId = await logWechatAuth(this.prisma, {
scene: 'CHOOSE_IMAGE',
requestUrl: input.pageUrl?.split('#')[0]?.slice(0, 512),
requestBody: {
status: input.status,
...(input.sourceType ? { sourceType: input.sourceType } : {}),
...(input.stage ? { stage: input.stage } : {}),
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
},
responseBody: { reported: true },
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
actorRef: input.actorRef,
});
return { ok: true, logId: logId.toString() };
}
}
@@ -0,0 +1,146 @@
import { BadRequestException, Body, Controller, Get, Inject, Post, Query, Req, UseGuards } from '@nestjs/common';
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { ClientApp } from '@dukang/shared-types';
import type { Request } from 'express';
import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { wechatActorRefFromAuth } from '../../integrations/wechat/wechat-log.util';
import { WechatLocationService } from './wechat-location.service';
class PhoneNumberDto {
@IsString()
@IsNotEmpty()
code: string;
@IsString()
@IsIn(['mini', 'h5'])
@IsOptional()
platform?: 'mini' | 'h5';
}
class WechatLocationDto {
@IsNumber()
@IsOptional()
latitude?: number;
@IsNumber()
@IsOptional()
longitude?: number;
@IsString()
@IsIn(['jssdk', 'geolocation'])
sdk: 'jssdk' | 'geolocation';
@IsString()
@IsIn(['success', 'fail'])
status: 'success' | 'fail';
@IsString()
@IsOptional()
errMsg?: string;
}
class WechatChooseImageDto {
@IsString()
@IsIn(['success', 'fail'])
status: 'success' | 'fail';
@IsString()
@IsOptional()
errMsg?: string;
@IsString()
@IsOptional()
sourceType?: string;
@IsString()
@IsIn(['jssdk', 'choose', 'read', 'empty'])
@IsOptional()
stage?: 'jssdk' | 'choose' | 'read' | 'empty';
@IsString()
@IsOptional()
pageUrl?: string;
}
@Controller('common/wechat')
export class WechatController {
constructor(
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
private readonly locationService: WechatLocationService,
) {}
@Get('jssdk-config')
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
if (!url) throw new BadRequestException('url 参数必填');
const decoded = decodeURIComponent(url).split('#')[0];
const pageUrl = this.normalizeJssdkUrl(decoded);
const user = (req as Request & { user?: AuthUser }).user;
const actorRef =
user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined;
return this.wechat.createJssdkConfig(pageUrl, actorRef);
}
private normalizeJssdkUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
parsed.hash = '';
parsed.searchParams.delete('code');
parsed.searchParams.delete('state');
const query = parsed.searchParams.toString();
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
} catch {
return rawUrl.split('#')[0];
}
}
@Get('oauth-url')
oauthUrl(
@Query('redirectUri') redirectUri: string,
@Query('state') state: string,
@Query('scope') scope?: string,
) {
if (!redirectUri || !state) throw new BadRequestException('redirectUri 与 state 必填');
return { url: this.wechat.buildOAuthUrl(redirectUri, state, scope) };
}
@Post('phone-number')
phoneNumber(@Body() dto: PhoneNumberDto) {
return this.wechat
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
.then((phone) => ({ phone }));
}
@Post('location')
@UseGuards(OptionalJwtAuthGuard)
reportLocation(@Req() req: Request, @Body() dto: WechatLocationDto) {
const user = (req as Request & { user?: AuthUser }).user;
const userId = user?.actorType === 'USER' ? user.actorId : undefined;
const clientApp = (req.headers['x-client-app'] as string) || ClientApp.USER_H5;
return this.locationService.reportLocation({
latitude: dto.latitude,
longitude: dto.longitude,
sdk: dto.sdk,
status: dto.status,
errMsg: dto.errMsg,
clientApp,
userId,
});
}
@Post('choose-image')
@UseGuards(OptionalJwtAuthGuard)
reportChooseImage(@Req() req: Request, @Body() dto: WechatChooseImageDto) {
const user = (req as Request & { user?: AuthUser }).user;
return this.locationService.reportChooseImage({
status: dto.status,
errMsg: dto.errMsg,
sourceType: dto.sourceType,
stage: dto.stage,
pageUrl: dto.pageUrl,
actorRef: wechatActorRefFromAuth(user?.actorType, user?.actorId),
});
}
}
@@ -0,0 +1,167 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { PrismaService } from '../../common/prisma/prisma.module';
import { DevPlanService } from './dev-plan.service';
import {
CreateDevPlanTaskDto,
CreateDevPlanVersionDto,
DevPlanTaskDispatchDto,
DevPlanTaskListQueryDto,
ReplaceVersionTasksDto,
UpdateDevPlanSettingsDto,
UpdateDevPlanTaskDto,
UpdateDevPlanVersionDto,
} from './dto/dev-plan.dto';
@Controller('admin/dev-plan')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('dev_plan')
export class AdminDevPlanController {
constructor(
private readonly service: DevPlanService,
private readonly prisma: PrismaService,
) {}
private async resolveHqAccount(user: AuthUser) {
const account = await this.prisma.hqAccount.findUnique({
where: { id: user.actorId },
select: { id: true, name: true },
});
if (!account) throw new NotFoundException('HQ 账户不存在');
return account;
}
@Get('tasks')
listTasks(@Query() query: DevPlanTaskListQueryDto) {
return this.service.listTasks(query);
}
@Post('tasks')
@HqOperation({ action: HqOperationAction.DEV_PLAN_TASK_CREATE, refType: 'DEV_PLAN_TASK', includeBody: true })
async createTask(@CurrentUser() user: AuthUser, @Body() body: CreateDevPlanTaskDto) {
const account = await this.resolveHqAccount(user);
return this.service.createTask(body, account.id);
}
@Get('tasks/:id')
getTask(@Param('id') id: string) {
return this.service.getTask(BigInt(id));
}
@Put('tasks/:id')
@HqOperation({
action: HqOperationAction.DEV_PLAN_TASK_UPDATE,
refType: 'DEV_PLAN_TASK',
refIdParam: 'id',
includeBody: true,
})
updateTask(@Param('id') id: string, @Body() body: UpdateDevPlanTaskDto) {
return this.service.updateTask(BigInt(id), body);
}
@Delete('tasks/:id')
@HqOperation({ action: HqOperationAction.DEV_PLAN_TASK_DELETE, refType: 'DEV_PLAN_TASK', refIdParam: 'id' })
deleteTask(@Param('id') id: string) {
return this.service.deleteTask(BigInt(id));
}
@Post('tasks/dispatch')
@HqOperation({ action: HqOperationAction.DEV_PLAN_TASK_DISPATCH, refType: 'DEV_PLAN_TASK', includeBody: true })
async dispatchTasks(@CurrentUser() user: AuthUser, @Body() body: DevPlanTaskDispatchDto) {
const account = await this.resolveHqAccount(user);
return this.service.dispatchTasks(body, account.id);
}
@Get('versions')
listVersions(
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listVersions({
status,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
@Post('versions')
@HqOperation({ action: HqOperationAction.DEV_PLAN_VERSION_CREATE, refType: 'DEV_PLAN_VERSION', includeBody: true })
createVersion(@Body() body: CreateDevPlanVersionDto) {
return this.service.createVersion(body);
}
@Get('versions/:id')
getVersion(@Param('id') id: string) {
return this.service.getVersion(BigInt(id));
}
@Put('versions/:id')
@HqOperation({
action: HqOperationAction.DEV_PLAN_VERSION_UPDATE,
refType: 'DEV_PLAN_VERSION',
refIdParam: 'id',
includeBody: true,
})
updateVersion(@Param('id') id: string, @Body() body: UpdateDevPlanVersionDto) {
return this.service.updateVersion(BigInt(id), body);
}
@Delete('versions/:id')
@HqOperation({ action: HqOperationAction.DEV_PLAN_VERSION_DELETE, refType: 'DEV_PLAN_VERSION', refIdParam: 'id' })
deleteVersion(@Param('id') id: string) {
return this.service.deleteVersion(BigInt(id));
}
@Put('versions/:id/tasks')
@HqOperation({
action: HqOperationAction.DEV_PLAN_VERSION_LINK_TASKS,
refType: 'DEV_PLAN_VERSION',
refIdParam: 'id',
includeBody: true,
})
replaceVersionTasks(@Param('id') id: string, @Body() body: ReplaceVersionTasksDto) {
return this.service.replaceVersionTasksApi(BigInt(id), body.taskIds);
}
@Post('versions/:id/tasks')
@HqOperation({
action: HqOperationAction.DEV_PLAN_VERSION_ADD_TASKS,
refType: 'DEV_PLAN_VERSION',
refIdParam: 'id',
includeBody: true,
})
addVersionTasks(@Param('id') id: string, @Body() body: ReplaceVersionTasksDto) {
return this.service.addVersionTasksApi(BigInt(id), body.taskIds);
}
@Get('settings')
getSettings() {
return this.service.getSettings();
}
@Put('settings')
@HqOperation({ action: HqOperationAction.DEV_PLAN_SETTINGS_UPDATE, refType: 'DEV_PLAN_SETTINGS', includeBody: true })
updateSettings(@Body() body: UpdateDevPlanSettingsDto) {
return this.service.updateSettings(body);
}
}
@@ -0,0 +1,21 @@
/**
* 企微群机器人 markdown/text 消息 @ 成员扩展语法。
* @see https://developer.work.weixin.qq.com/document/path/91770
*/
/** 生成 `<@userid>` 片段 */
export function formatWecomAtMention(wecomUserId?: string | null): string {
const uid = (wecomUserId || '').trim();
return uid ? `<@${uid}>` : '';
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { LlmModule } from '../../integrations/llm/llm.module';
import { DevPlanService } from './dev-plan.service';
import { SupportTicketReviewAiService } from './support-ticket-review-ai.service';
@Module({
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
DEV_PLAN_TASK_STATUSES,
DEV_PLAN_TASK_TYPES,
DEV_PLAN_VERSION_STATUSES,
} from '@dukang/shared-types';
export class DevPlanTaskListQueryDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
page?: number;
@IsOptional()
pageSize?: number;
}
export class CreateDevPlanTaskDto {
@IsString()
@IsNotEmpty()
content!: string;
@IsIn(DEV_PLAN_TASK_TYPES)
type!: string;
@IsOptional()
@IsString()
supportTicketId?: string;
}
export class UpdateDevPlanTaskDto {
@IsOptional()
@IsString()
@IsNotEmpty()
content?: string;
@IsOptional()
@IsIn(DEV_PLAN_TASK_TYPES)
type?: string;
@IsOptional()
@IsIn(DEV_PLAN_TASK_STATUSES)
status?: string;
}
export class CreateDevPlanVersionDto {
@IsString()
@IsNotEmpty()
versionNo!: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsIn(DEV_PLAN_VERSION_STATUSES)
status?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
taskIds?: string[];
}
export class UpdateDevPlanVersionDto {
@IsOptional()
@IsString()
@IsNotEmpty()
versionNo?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsIn(DEV_PLAN_VERSION_STATUSES)
status?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
taskIds?: string[];
}
export class ReplaceVersionTasksDto {
@IsArray()
@IsString({ each: true })
taskIds!: string[];
}
export class UpdateDevPlanSettingsDto {
@IsOptional()
@IsString()
reviewAssistantLlmConfigId?: string | null;
@IsOptional()
@IsString()
reviewAssistantKnowledgeBaseId?: string | null;
@IsOptional()
@IsString()
reviewAssistantPrompt?: string | null;
}
export class DevPlanTaskDispatchDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
taskIds!: string[];
@IsOptional()
@IsString()
supplement?: string;
}
export class DevPlanTaskFromTicketDto {
@IsString()
@IsNotEmpty()
content!: string;
@IsIn(DEV_PLAN_TASK_TYPES)
type!: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
}
export class ReviewSupportTicketDto {
@IsIn(['APPROVE', 'REJECT'])
decision!: 'APPROVE' | 'REJECT';
@ValidateIf((o: ReviewSupportTicketDto) => o.decision === 'REJECT')
@IsString()
@IsNotEmpty()
rejectReason?: string;
@IsOptional()
@IsString()
note?: string;
@ValidateIf((o: ReviewSupportTicketDto) => o.decision === 'APPROVE')
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => DevPlanTaskFromTicketDto)
tasks?: DevPlanTaskFromTicketDto[];
}
export class BatchReviewPreviewDto {
@IsArray()
@IsString({ each: true })
ticketIds!: string[];
}
export class BatchReviewConfirmDto {
@IsArray()
items!: Array<{
ticketId: string;
decision: 'APPROVE' | 'REJECT';
rejectReason?: string;
note?: string;
tasks?: Array<{ content: string; type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION' }>;
}>;
}
@@ -0,0 +1,157 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { BatchReviewPreviewItem } from '@dukang/shared-types';
import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { LlmChatClient } from '../../integrations/llm/llm-chat.client';
import { KnowledgeRetrievalService } from '../../integrations/llm/knowledge-retrieval.service';
import { DevPlanService } from './dev-plan.service';
const DEFAULT_REVIEW_PROMPT = [
'你是杜康好客技术支持工单审核助手。',
'根据工单内容与知识库片段,给出审批建议:通过(APPROVE)或驳回(REJECT)。',
'通过时可建议拆分为 1~3 条开发任务(content + type: BUG/REQUIREMENT/OPTIMIZATION)。',
'仅输出 JSON,格式:',
'{"decision":"APPROVE|REJECT","rejectReason":"驳回时必填","note":"通过时附注","reportMarkdown":"markdown摘要","suggestedTasks":[{"content":"...","type":"BUG"}]}',
].join('\n');
@Injectable()
export class SupportTicketReviewAiService {
private readonly logger = new Logger(SupportTicketReviewAiService.name);
constructor(
private readonly prisma: PrismaService,
private readonly llm: LlmChatClient,
private readonly kb: KnowledgeRetrievalService,
private readonly devPlan: DevPlanService,
) {}
async preview(ticketIds: string[]): Promise<{ items: BatchReviewPreviewItem[] }> {
if (!ticketIds.length) throw new BadRequestException('请选择工单');
const settings = await this.devPlan.getSettingsRaw();
if (!settings.reviewAssistantLlmConfigId) {
throw new BadRequestException('请先在开发设置中配置审核 AI 助手的语言模型');
}
const llmCfg = await this.prisma.llmApiConfig.findUnique({
where: { id: settings.reviewAssistantLlmConfigId },
});
if (!llmCfg?.enabled) throw new BadRequestException('审核 AI 助手绑定的语言模型未启用');
const ids = ticketIds.map(BigInt);
const tickets = await this.prisma.commonSupportTicket.findMany({
where: { id: { in: ids }, status: 'PENDING_REVIEW' },
});
if (tickets.length !== ids.length) {
throw new BadRequestException('部分工单不存在或不在待评审状态');
}
const items: BatchReviewPreviewItem[] = [];
for (const ticket of tickets) {
let kbBlock = '';
if (settings.reviewAssistantKnowledgeBaseId) {
try {
kbBlock = await this.kb.buildContext(
settings.reviewAssistantKnowledgeBaseId,
`${ticket.title}\n${ticket.content ?? ''}`,
);
} catch (e) {
this.logger.warn(`review kb failed: ${String(e)}`);
}
}
const userPrompt = [
`工单号:${ticket.ticketNo}`,
`类型:${ticket.ticketType}`,
`标题:${ticket.title}`,
`内容:${ticket.content ?? '(无)'}`,
kbBlock ? `\n知识库片段:\n${kbBlock}` : '',
].join('\n');
const system = settings.reviewAssistantPrompt?.trim() || DEFAULT_REVIEW_PROMPT;
const raw = await this.llm.chat({
baseUrl: llmCfg.baseUrl,
apiKey: llmCfg.apiKey,
model: llmCfg.modelName,
temperature: 0.2,
maxTokens: 2048,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userPrompt },
],
});
const parsed = parseReviewJson(raw, ticket.ticketType);
items.push({
ticketId: String(ticket.id),
ticketNo: ticket.ticketNo,
title: ticket.title,
decision: parsed.decision,
rejectReason: parsed.rejectReason,
note: parsed.note,
reportMarkdown: parsed.reportMarkdown,
suggestedTasks: parsed.suggestedTasks,
});
}
return { items };
}
}
function parseReviewJson(
raw: string,
ticketType: 'BUG' | 'SUGGESTION' | 'OTHER',
): {
decision: 'APPROVE' | 'REJECT';
rejectReason?: string;
note?: string;
reportMarkdown: string;
suggestedTasks: Array<{ content: string; type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION' }>;
} {
const jsonMatch = raw.match(/\{[\s\S]*\}/);
const fallbackType = mapSupportTicketTypeToDevPlanTask(ticketType);
if (!jsonMatch) {
return {
decision: 'APPROVE',
note: 'AI 未返回结构化结果,请人工确认',
reportMarkdown: raw.slice(0, 2000),
suggestedTasks: [{ content: '待人工填写任务内容', type: fallbackType }],
};
}
try {
const obj = JSON.parse(jsonMatch[0]) as {
decision?: string;
rejectReason?: string;
note?: string;
reportMarkdown?: string;
suggestedTasks?: Array<{ content?: string; type?: string }>;
};
const decision = obj.decision === 'REJECT' ? 'REJECT' : 'APPROVE';
const suggestedTasks =
decision === 'APPROVE'
? (obj.suggestedTasks ?? [])
.filter((t) => t.content?.trim())
.map((t) => ({
content: t.content!.trim(),
type: (['BUG', 'REQUIREMENT', 'OPTIMIZATION'].includes(String(t.type))
? t.type
: fallbackType) as 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION',
}))
: [];
if (decision === 'APPROVE' && !suggestedTasks.length) {
suggestedTasks.push({ content: '待人工填写任务内容', type: fallbackType });
}
return {
decision,
rejectReason: obj.rejectReason?.trim() || undefined,
note: obj.note?.trim() || undefined,
reportMarkdown: obj.reportMarkdown?.trim() || raw.slice(0, 2000),
suggestedTasks,
};
} catch {
return {
decision: 'APPROVE',
note: 'AI 返回解析失败,请人工确认',
reportMarkdown: raw.slice(0, 2000),
suggestedTasks: [{ content: '待人工填写任务内容', type: fallbackType }],
};
}
}
@@ -0,0 +1,441 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
Prisma,
} from '@prisma/client';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
isXfxProviderCode,
type LogisticsPricingRuleDto,
type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config';
export type CreateFulfillmentProviderInput = {
code: string;
name: string;
type: FulfillmentProviderType;
status?: FulfillmentProviderStatus;
configJson?: string;
capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: LogisticsSettlementMethod | string;
pricingRules?: LogisticsPricingRuleDto | null;
};
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
type Capabilities = {
createShipment?: boolean;
getTrack?: boolean;
callback?: boolean;
cancel?: boolean;
};
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
@Injectable()
export class FulfillmentProviderService {
constructor(private readonly prisma: PrismaService) {}
async listActiveApiProviders() {
const rows = await this.prisma.fulfillmentProvider.findMany({
where: { status: 'ACTIVE', type: 'API' },
orderBy: { name: 'asc' },
});
return rows.map((row) => this.toDto(row));
}
async listAll() {
const rows = await this.prisma.fulfillmentProvider.findMany({
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async getById(id: bigint) {
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!row) throw new NotFoundException('仓配承运商不存在');
return this.toDto(row);
}
/** 供推单使用:解析完整小飞侠凭证(含 apiKey) */
async resolveXiaofeixiaConfig(providerId: bigint): Promise<XiaofeixiaConfig> {
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
if (!isXfxProviderCode(row.code)) {
throw new BadRequestException('该承运商不是小飞侠');
}
const cfg = this.parseXiaofeixiaConfig(row.configJson);
if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) {
throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key');
}
return {
apiUrl: cfg.apiUrl,
mchId: cfg.mchId,
apiKey: cfg.apiKey,
signType: this.resolveSignType(cfg.signType),
appId: cfg.appId || undefined,
};
}
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
const row = await this.prisma.fulfillmentProvider.findFirst({
where: {
status: 'ACTIVE',
type: 'API',
code: { in: ['XFX', 'XIAOFEIXIA'] },
},
orderBy: { updatedAt: 'desc' },
});
if (!row?.configJson) return null;
try {
return await this.resolveXiaofeixiaConfig(row.id);
} catch {
return null;
}
}
async create(input: CreateFulfillmentProviderInput) {
const code = input.code.trim().toUpperCase();
if (!/^[A-Z0-9_]+$/.test(code)) {
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
}
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
if (existing) throw new BadRequestException('承运商编码已存在');
const configJson = this.resolveConfigJsonForWrite(code, null, input);
if (isXfxProviderCode(code) && input.type === 'API') {
this.assertXiaofeixiaConfigComplete(configJson, true);
}
const pricingRulesJson = this.resolvePricingRulesJson(
code,
input.pricingRules,
isXfxProviderCode(code) ? DEFAULT_XFX_LOGISTICS_PRICING : null,
);
const settlementMethod = this.parseSettlementMethod(input.settlementMethod) ?? 'PREPAID';
const row = await this.prisma.fulfillmentProvider.create({
data: {
code,
name: input.name.trim(),
type: input.type,
status: input.status ?? 'ACTIVE',
configJson,
capabilitiesJson:
input.capabilitiesJson?.trim() ||
(isXfxProviderCode(code)
? JSON.stringify({
createShipment: true,
getTrack: true,
callback: true,
cancel: true,
})
: null),
bankAccountName: this.normOptional(input.bankAccountName),
bankName: this.normOptional(input.bankName),
bankBranch: this.normOptional(input.bankBranch),
bankAccountNo: this.normOptional(input.bankAccountNo),
settlementMethod,
pricingRulesJson,
},
});
return this.toDto(row);
}
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓配承运商不存在');
const code = current.code;
const configJson =
input.xiaofeixiaConfig !== undefined || input.configJson !== undefined
? this.resolveConfigJsonForWrite(code, current.configJson, input)
: undefined;
if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') {
this.assertXiaofeixiaConfigComplete(configJson, false);
}
const pricingRulesJson =
input.pricingRules !== undefined
? this.resolvePricingRulesJson(code, input.pricingRules, null)
: undefined;
const row = await this.prisma.fulfillmentProvider.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.type !== undefined ? { type: input.type } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(configJson !== undefined ? { configJson } : {}),
...(input.capabilitiesJson !== undefined
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
: {}),
...(input.bankAccountName !== undefined
? { bankAccountName: this.normOptional(input.bankAccountName) }
: {}),
...(input.bankName !== undefined ? { bankName: this.normOptional(input.bankName) } : {}),
...(input.bankBranch !== undefined ? { bankBranch: this.normOptional(input.bankBranch) } : {}),
...(input.bankAccountNo !== undefined
? { bankAccountNo: this.normOptional(input.bankAccountNo) }
: {}),
...(input.settlementMethod !== undefined
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
: {}),
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
},
});
return this.toDto(row);
}
/** 充值(结算模块可复用) */
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
const rounded = Math.round(amount * 100) / 100;
const result = await this.prisma.$transaction(async (tx) => {
const row = await tx.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
const balanceAfter = Math.round((Number(row.prepaidBalance) + rounded) * 100) / 100;
const updated = await tx.fulfillmentProvider.update({
where: { id: providerId },
data: { prepaidBalance: balanceAfter },
});
const ledger = await tx.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: providerId,
type: 'RECHARGE',
amount: rounded,
balanceAfter,
remark: remark?.trim() || '充值',
},
});
return { provider: updated, ledger };
});
return serializeBigInt({
provider: this.toDto(result.provider),
ledger: result.ledger,
});
}
/** 账单扣减充值余额;余额不足返回 false */
async deductPrepaid(
providerId: bigint,
amount: number,
logisticsBillId: bigint,
tx?: Prisma.TransactionClient,
): Promise<{ ok: true; balanceAfter: number } | { ok: false; balance: number }> {
const client = tx ?? this.prisma;
const rounded = Math.round(amount * 100) / 100;
const row = await client.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
const balance = Number(row.prepaidBalance);
if (balance + 1e-9 < rounded) {
return { ok: false, balance };
}
const balanceAfter = Math.round((balance - rounded) * 100) / 100;
await client.fulfillmentProvider.update({
where: { id: providerId },
data: { prepaidBalance: balanceAfter },
});
await client.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: providerId,
type: 'DEDUCT',
amount: rounded,
balanceAfter,
logisticsBillId,
remark: '物流月账单扣款',
},
});
return { ok: true, balanceAfter };
}
parseCapabilities(raw: string | null): Capabilities | null {
if (!raw) return null;
try {
return JSON.parse(raw) as Capabilities;
} catch {
return null;
}
}
parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<XiaofeixiaProviderConfig>;
if (!parsed || typeof parsed !== 'object') return null;
return {
apiUrl: String(parsed.apiUrl ?? '').trim(),
mchId: String(parsed.mchId ?? '').trim(),
apiKey: String(parsed.apiKey ?? '').trim(),
signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
appId: parsed.appId ? String(parsed.appId).trim() : undefined,
};
} catch {
return null;
}
}
parsePricingRules(raw: string | null): LogisticsPricingRuleDto | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<LogisticsPricingRuleDto>;
if (!parsed || typeof parsed !== 'object') return null;
const baseBottles = Number(parsed.baseBottles);
const baseFee = Number(parsed.baseFee);
const extraBottleFee = Number(parsed.extraBottleFee);
if (!(baseBottles > 0) || !(baseFee >= 0) || !(extraBottleFee >= 0)) return null;
const rule: LogisticsPricingRuleDto = { baseBottles, baseFee, extraBottleFee };
if (parsed.boxBottles != null && Number(parsed.boxBottles) > 0) {
rule.boxBottles = Number(parsed.boxBottles);
}
if (parsed.boxFee != null && Number(parsed.boxFee) >= 0) {
rule.boxFee = Number(parsed.boxFee);
}
return rule;
} catch {
return null;
}
}
private resolvePricingRulesJson(
code: string,
input: LogisticsPricingRuleDto | null | undefined,
fallback: LogisticsPricingRuleDto | null,
): string | null {
if (input === null) return null;
const rule = input ?? fallback;
if (!rule) return isXfxProviderCode(code) ? JSON.stringify(DEFAULT_XFX_LOGISTICS_PRICING) : null;
if (!(rule.baseBottles > 0)) throw new BadRequestException('计价标准:起送瓶数须大于 0');
if (!(rule.baseFee >= 0)) throw new BadRequestException('计价标准:起送费用无效');
if (!(rule.extraBottleFee >= 0)) throw new BadRequestException('计价标准:加瓶费用无效');
return JSON.stringify({
baseBottles: Number(rule.baseBottles),
baseFee: Number(rule.baseFee),
extraBottleFee: Number(rule.extraBottleFee),
...(rule.boxBottles != null ? { boxBottles: Number(rule.boxBottles) } : {}),
...(rule.boxFee != null ? { boxFee: Number(rule.boxFee) } : {}),
});
}
private parseSettlementMethod(
raw?: LogisticsSettlementMethod | string | null,
): LogisticsSettlementMethod | null {
if (raw == null || raw === '') return null;
if (raw === 'PREPAID' || raw === 'MONTHLY_CREDIT') return raw;
throw new BadRequestException('结算方式仅支持 PREPAID / MONTHLY_CREDIT');
}
private normOptional(v?: string | null) {
if (v === undefined) return undefined;
if (v == null) return null;
const t = String(v).trim();
return t || null;
}
private resolveConfigJsonForWrite(
code: string,
existingRaw: string | null,
input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput,
): string | null {
if (isXfxProviderCode(code) && input.xiaofeixiaConfig) {
const existing = this.parseXiaofeixiaConfig(existingRaw);
const next: XiaofeixiaProviderConfig = {
apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(),
mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(),
apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(),
signType:
input.xiaofeixiaConfig.signType === 'HMAC-SHA256'
? 'HMAC-SHA256'
: input.xiaofeixiaConfig.signType === 'MD5'
? 'MD5'
: existing?.signType ?? 'MD5',
appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined,
};
return JSON.stringify(next);
}
if (input.configJson !== undefined) {
return input.configJson?.trim() || null;
}
return existingRaw;
}
private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) {
const cfg = this.parseXiaofeixiaConfig(configJson);
if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址');
if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号');
if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key');
if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写');
}
private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null {
const cfg = this.parseXiaofeixiaConfig(raw);
if (!cfg) return null;
return {
apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL,
mchId: cfg.mchId,
signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
appId: cfg.appId,
hasApiKey: Boolean(cfg.apiKey),
};
}
private resolveSignType(raw?: string): XiaofeixiaSignType {
return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5';
}
private toDto(row: {
id: bigint;
code: string;
name: string;
type: string;
status: string;
configJson: string | null;
capabilitiesJson: string | null;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
settlementMethod?: string;
pricingRulesJson?: string | null;
prepaidBalance?: Prisma.Decimal | number;
createdAt: Date;
updatedAt: Date;
}) {
return serializeBigInt({
id: row.id.toString(),
code: row.code,
name: row.name,
type: row.type,
status: row.status,
capabilities: this.parseCapabilities(row.capabilitiesJson),
hasConfig: Boolean(row.configJson),
xiaofeixiaConfig: isXfxProviderCode(row.code)
? this.toPublicXiaofeixiaConfig(row.configJson)
: null,
bankAccountName: row.bankAccountName ?? null,
bankName: row.bankName ?? null,
bankBranch: row.bankBranch ?? null,
bankAccountNo: row.bankAccountNo ?? null,
settlementMethod: row.settlementMethod ?? 'PREPAID',
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
prepaidBalance: Number(row.prepaidBalance ?? 0),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -0,0 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { TradeModule } from '../trade/trade.module';
import { FulfillmentProviderService } from './fulfillment-provider.service';
import { FulfillmentService } from './fulfillment.service';
@Module({
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
providers: [FulfillmentProviderService, FulfillmentService],
exports: [FulfillmentProviderService, FulfillmentService],
})
export class FulfillmentModule {}
@@ -0,0 +1,369 @@
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
import { isXfxProviderCode } from '@dukang/shared-types';
import {
BOTTLES_PER_BOX,
XFX_AUTO_DISPATCH_MAX_BOXES,
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service';
export type ManualShipInput = {
logisticsCompany: string;
trackingNo: string;
manualQueryUrl?: string;
};
export type HqLogisticsShipInput = ManualShipInput;
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
@Injectable()
export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly courier: CourierService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
@Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService,
) {}
async dispatchAfterPay(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order || order.payStatus !== 'PAID') return;
if (order.deliveryType === 'CROSS_CITY') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
if (!warehouse) {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
await this.prisma.order.update({
where: { id: orderId },
data: { fulfillmentWarehouseId: warehouse.id },
});
if (warehouse.fulfillmentMode === 'MANUAL') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: warehouse.fulfillmentProviderId },
});
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
if (shouldHoldAutoCourierDispatch(order.quantity)) {
const boxes = calcOrderBoxCount(order.quantity);
this.logger.warn(
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
);
await this.prisma.order.update({
where: { id: orderId },
data: {
fulfillmentHold: true,
fulfillmentHoldReason: FULFILLMENT_HOLD_LARGE_ORDER,
},
});
await this.ensureDeliveryRecord(orderId, 'MANUAL', provider.id);
await this.prisma.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH_HOLD',
refType: 'ORDER',
refId: order.id,
status: 'PENDING',
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
0,
512,
),
},
});
return;
}
await this.dispatchApiAuto(order, warehouse, provider);
}
}
async clearFulfillmentHold(orderId: bigint) {
await this.prisma.order.update({
where: { id: orderId },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
}
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!isXfxProviderCode(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return;
}
let xfxConfig: XiaofeixiaConfig;
try {
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return;
}
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
try {
const result = await this.courier.createShipment(
{
outNumber: order.orderNo,
from: {
name: warehouse.contactName,
mobile: warehouse.contactPhone,
address: warehouse.address,
addressDetail: warehouse.name,
coordinate: { lng: fromLng, lat: fromLat },
},
to: {
name: order.receiverName,
mobile: order.receiverPhone,
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
addressDetail: order.receiverAddress,
},
goodsName: order.productName,
goodsNum: order.quantity,
weight: 2,
payMode: CourierPayMode.SENDER,
remark: `仓配自动发货 ${order.orderNo}`,
},
{ xiaofeixia: xfxConfig },
);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
const data = {
provider: 'XFX' as const,
fulfillmentProviderId: provider.id,
trackingNo: result.trackingNumber,
providerOrderNo: String(result.providerShipmentId),
shippingAt: now,
};
if (delivery) {
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
} else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
}
await tx.order.update({
where: { id: order.id },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
await tx.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH',
refType: 'ORDER',
refId: order.id,
externalNo: result.trackingNumber,
status: 'SUCCESS',
},
});
});
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
}
}
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在或无权操作');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
const queryUrl =
input.manualQueryUrl?.trim() ||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
return this.applyManualShip(order, {
logisticsCompany: input.logisticsCompany.trim(),
trackingNo: input.trackingNo.trim(),
manualQueryUrl: queryUrl,
operator: 'WAREHOUSE_MANUAL',
});
}
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在');
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
return this.applyManualShip(order, {
logisticsCompany: input.logisticsCompany.trim(),
trackingNo: input.trackingNo.trim(),
manualQueryUrl: input.manualQueryUrl?.trim(),
operator: 'HQ_LOGISTICS',
provider: 'LOGISTICS',
});
}
async getOrderTrack(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order?.delivery) {
return { nodes: [], manualQueryUrl: null };
}
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
try {
const options = order.delivery.fulfillmentProviderId
? {
xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
order.delivery.fulfillmentProviderId,
),
}
: undefined;
const nodes = await this.courier.getTrack(
{
trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo,
},
options,
);
return {
nodes,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
};
} catch {
// fall through
}
}
return {
nodes: [],
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
};
}
private async applyManualShip(
order: Order & { delivery: { trackingNo: string | null } | null },
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
) {
const now = new Date();
const provider = input.provider ?? 'MANUAL';
await this.prisma.$transaction(async (tx) => {
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
const data = {
provider,
logisticsCompany: input.logisticsCompany,
trackingNo: input.trackingNo,
manualQueryUrl: input.manualQueryUrl || null,
shippingAt: now,
};
if (delivery) {
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
} else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
}
await tx.order.update({
where: { id: order.id },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
});
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
return this.prisma.order.findUnique({
where: { id: order.id },
include: { delivery: true, fulfillmentWarehouse: true },
});
}
private async resolveWarehouseForLocalOrder(cityId: bigint) {
return this.prisma.cityWarehouse.findFirst({
where: { cityId, status: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
});
}
private async ensureDeliveryRecord(
orderId: bigint,
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
fulfillmentProviderId?: bigint,
) {
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
if (existing) return;
await this.prisma.orderDelivery.create({
data: {
orderId,
provider,
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
},
});
}
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
await this.prisma.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH',
refType: 'ORDER',
refId: order.id,
status: 'FAILED',
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
},
});
}
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
if (!warehouseId) return undefined;
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
const tpl = wh?.manualQueryUrlTemplate;
if (!tpl) return undefined;
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
}
}
@@ -0,0 +1,35 @@
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
async check() {
let db: 'ok' | 'error' = 'ok';
let redis: 'ok' | 'error' = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
db = 'error';
}
try {
const pong = await this.redis.client.ping();
if (pong !== 'PONG') redis = 'error';
} catch {
redis = 'error';
}
const status = db === 'ok' && redis === 'ok' ? 'ok' : 'degraded';
return {
status,
service: 'dukang-api',
version: 'prev1',
checks: { db, redis },
};
}
}
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
/** Prisma / Redis 已为 GlobalHealth 直接注入探活 */
@Module({ controllers: [HealthController] })
export class HealthModule {}
@@ -0,0 +1,38 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginPasswordDto, LoginSmsDto, LoginWechatDto, SendSmsDto } from './dto/auth.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
import { ClientApp } from '@dukang/shared-types';
@Controller('admin/auth')
export class AdminAuthController {
constructor(private readonly authService: AuthService) {}
@Post('sms/send')
sendSms(@Body() dto: SendSmsDto) {
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.HQ_WEB });
}
@Post('login/sms')
login(@Body() dto: LoginSmsDto) {
return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB);
}
@Post('login/password')
loginPassword(@Body() dto: LoginPasswordDto) {
return this.authService.loginHqPassword(dto.loginName, dto.password, ClientApp.HQ_WEB);
}
@Post('login/wechat')
wechatLogin(@Body() dto: LoginWechatDto) {
return this.authService.loginHqWechat(dto.code, ClientApp.HQ_WEB, dto.platform ?? 'h5');
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.authService.getMe(user.actorType, user.actorId);
}
}
@@ -0,0 +1,246 @@
import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import {
BindPhoneDto,
BindWechatDto,
BindWechatPhoneDto,
MiniWechatProfileDto,
BootstrapSessionDto,
CheckPartnerPhoneDto,
LoginSmsDto,
LoginWechatDto,
LoginWechatPhoneDto,
RefreshTokenDto,
SendSmsDto,
} from './dto/auth.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
import { ClientApp } from '@dukang/shared-types';
function resolveUserClientApp(req: Request): ClientApp {
const header = String(req.headers['x-client-app'] || '').trim();
if (header === ClientApp.USER_MINI) return ClientApp.USER_MINI;
return ClientApp.USER_H5;
}
@Controller()
export class UserAuthController {
constructor(private readonly authService: AuthService) {}
@Post('auth/session/bootstrap')
bootstrap(@Req() req: Request, @Body() dto: BootstrapSessionDto) {
return this.authService.bootstrapSession(dto.deviceKey, resolveUserClientApp(req));
}
@Post('auth/token/refresh')
refresh(@Req() req: Request, @Body() dto: RefreshTokenDto) {
return this.authService.refreshAccessToken(dto.refreshToken, resolveUserClientApp(req));
}
@Post('auth/sms/send')
@UseGuards(OptionalJwtAuthGuard)
sendSms(@Req() req: Request, @Body() dto: SendSmsDto) {
const guest = (req as Request & { user?: AuthUser }).user;
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
return this.authService.sendSms(dto.phone, dto.scene, {
guestUserId: guestId,
clientApp: resolveUserClientApp(req),
});
}
@Post('auth/login/sms')
@UseGuards(OptionalJwtAuthGuard)
login(@Req() req: Request, @Body() dto: LoginSmsDto) {
const guest = (req as Request & { user?: AuthUser }).user;
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
return this.authService.loginUser(dto.phone, dto.code, resolveUserClientApp(req), guestId);
}
@Post('auth/phone/bind')
@UseGuards(JwtAuthGuard)
bindPhone(@Req() req: Request, @CurrentUser() user: AuthUser, @Body() dto: BindPhoneDto) {
return this.authService.bindPhone(user.actorId, dto.phone, dto.code, resolveUserClientApp(req));
}
@Post('auth/login/wechat')
@UseGuards(OptionalJwtAuthGuard)
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
const guest = (req as Request & { user?: AuthUser }).user;
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
const clientApp = resolveUserClientApp(req);
const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5');
return this.authService.loginUserWechat(dto.code, clientApp, platform, guestId);
}
/** 小程序手机号快捷登录(getPhoneNumber */
@Post('auth/login/wechat-phone')
@UseGuards(OptionalJwtAuthGuard)
wechatPhoneLogin(@Req() req: Request, @Body() dto: LoginWechatPhoneDto) {
const guest = (req as Request & { user?: AuthUser }).user;
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
const clientApp = resolveUserClientApp(req);
const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5');
return this.authService.loginUserWechatPhone(
dto.phoneCode,
clientApp,
platform,
guestId,
dto.loginCode,
);
}
@Post('auth/wechat/bind-phone')
bindPhoneLegacy(@Req() req: Request, @Body() dto: BindWechatPhoneDto) {
return this.authService.bindWechatPhone(
dto.wxSessionKey,
dto.phone,
dto.code,
resolveUserClientApp(req),
);
}
@Post('auth/wechat/bind')
@UseGuards(JwtAuthGuard)
bindWechat(@Req() req: Request, @CurrentUser() user: AuthUser, @Body() dto: BindWechatDto) {
const clientApp = resolveUserClientApp(req);
const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5');
return this.authService.bindUserWechat(
user.actorId,
{ code: dto.code, wxSessionKey: dto.wxSessionKey },
clientApp,
platform,
);
}
@Post('auth/wechat/mini-profile')
@UseGuards(JwtAuthGuard)
updateMiniWechatProfile(
@Req() req: Request,
@CurrentUser() user: AuthUser,
@Body() dto: MiniWechatProfileDto,
) {
if (user.actorType !== 'USER') {
throw new BadRequestException('仅用户可更新资料');
}
const clientApp = resolveUserClientApp(req);
if (clientApp !== ClientApp.USER_MINI) {
throw new BadRequestException('仅小程序端可调用');
}
return this.authService.updateMiniWechatProfile(user.actorId, dto);
}
@Get('auth/me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.authService.getMe(user.actorType, user.actorId);
}
}
@Controller('shop/auth')
export class ShopAuthController {
constructor(private readonly authService: AuthService) {}
@Post('sms/send')
sendSms(@Body() dto: SendSmsDto) {
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.SHOP_H5 });
}
@Post('login/sms')
login(@Body() dto: LoginSmsDto) {
return this.authService.loginStore(dto.phone, dto.code, ClientApp.SHOP_H5);
}
@Post('login/wechat')
@UseGuards(OptionalJwtAuthGuard)
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
const user = (req as Request & { user?: AuthUser }).user;
if (user?.actorType === 'STORE') {
return this.authService.bindStoreWechat(
user.actorId,
dto.code,
ClientApp.SHOP_H5,
dto.platform ?? 'h5',
user.storeId,
);
}
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
}
@Post('token/refresh')
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.SHOP_H5);
}
@Get('stores')
@UseGuards(JwtAuthGuard)
stores(@CurrentUser() user: AuthUser) {
return this.authService.listShopStores(user.actorId);
}
@Post('select-store')
@UseGuards(JwtAuthGuard)
selectStore(@CurrentUser() user: AuthUser, @Body() body: { storeId: string }) {
if (!body?.storeId) throw new BadRequestException('请选择门店');
return this.authService.selectShopStore(user.actorId, BigInt(body.storeId), ClientApp.SHOP_H5);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.authService.getShopMe(user);
}
}
@Controller('partner/auth')
export class PartnerAuthController {
constructor(private readonly authService: AuthService) {}
@Post('phone/check')
checkPhone(@Body() dto: CheckPartnerPhoneDto) {
return this.authService.checkPartnerPhone(dto.phone);
}
@Post('sms/send')
sendSms(@Body() dto: SendSmsDto) {
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.PARTNER_H5 });
}
@Post('login/sms')
login(@Body() dto: LoginSmsDto) {
return this.authService.loginPartner(dto.phone, dto.code, ClientApp.PARTNER_H5);
}
@Post('login/wechat')
@UseGuards(OptionalJwtAuthGuard)
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
const user = (req as Request & { user?: AuthUser }).user;
if (user?.actorType === 'PARTNER') {
return this.authService.bindPartnerWechat(
user.actorId,
dto.code,
ClientApp.PARTNER_H5,
dto.platform ?? 'h5',
);
}
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
}
@Post('token/refresh')
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.PARTNER_H5);
}
}
@Controller('user')
export class UserProfileController {
constructor(private readonly authService: AuthService) {}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthUser) {
return this.authService.getMe(user.actorType, user.actorId);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { SmsScene } from '@dukang/shared-types';
export class SendSmsDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
@IsIn(Object.values(SmsScene))
scene: string;
}
export class LoginSmsDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
code: string;
}
export class BootstrapSessionDto {
@IsString()
@IsOptional()
deviceKey?: string;
}
export class RefreshTokenDto {
@IsString()
@IsNotEmpty()
refreshToken: string;
}
export class BindPhoneDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
code: string;
}
export class LoginWechatDto {
@IsString()
@IsNotEmpty()
code: string;
@IsString()
@IsIn(['h5', 'mini'])
@IsOptional()
platform?: 'h5' | 'mini';
}
/** 小程序 getPhoneNumber 返回的 phoneCode,可选附带 wx.login code 绑定 openId */
export class LoginWechatPhoneDto {
@IsString()
@IsNotEmpty()
phoneCode: string;
@IsString()
@IsOptional()
loginCode?: string;
@IsString()
@IsIn(['h5', 'mini'])
@IsOptional()
platform?: 'h5' | 'mini';
}
export class BindWechatPhoneDto {
@IsString()
@IsNotEmpty()
wxSessionKey: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
code: string;
}
export class LoginPasswordDto {
@IsString()
@IsNotEmpty()
loginName: string;
@IsString()
@IsNotEmpty()
password: string;
}
export class BindWechatDto {
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
wxSessionKey?: string;
@IsString()
@IsIn(['h5', 'mini'])
@IsOptional()
platform?: 'h5' | 'mini';
}
export class MiniWechatProfileDto {
@IsString()
@IsOptional()
nickname?: string;
@IsString()
@IsOptional()
avatarUrl?: string;
@IsString()
@IsOptional()
avatarResourceId?: string;
}
export class CheckPartnerPhoneDto {
@IsString()
@IsNotEmpty()
phone: string;
}
@@ -0,0 +1,54 @@
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
export class SendPartnerStaffPhoneSmsDto {
@IsString()
@IsNotEmpty()
phone: string;
}
export class CreatePartnerStaffDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
smsCode: string;
@IsString()
@IsNotEmpty()
name: string;
/** 未传时服务端默认 INTERNAL */
@IsOptional()
@IsString()
@IsIn(Object.values(PartnerStaffRole))
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class UpdatePartnerStaffDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsIn(Object.values(PartnerStaffRole))
@IsOptional()
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsString()
@IsIn(Object.values(AccountStatus))
@IsOptional()
status?: string;
}
@@ -0,0 +1,52 @@
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { AccountStatus, StoreStaffRole } from '@dukang/shared-types';
export class CreateStoreStaffDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsArray()
@IsString({ each: true })
storeIds: string[];
@IsOptional()
@IsString()
@IsIn(Object.values(StoreStaffRole))
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class UpdateStoreStaffDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsIn(Object.values(StoreStaffRole))
@IsOptional()
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsString()
@IsIn(Object.values(AccountStatus))
@IsOptional()
status?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
storeIds?: string[];
}
@@ -0,0 +1,90 @@
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { AuthService } from './auth.service';
import {
PartnerAuthController,
ShopAuthController,
UserAuthController,
UserProfileController,
} from './auth.controller';
import { UserAddressController } from './user-address.controller';
import { UserAddressService } from './user-address.service';
import { PartnerStaffController } from './partner-staff.controller';
import { PartnerStaffService } from './partner-staff.service';
import { StoreStaffController } from './store-staff.controller';
import { StoreStaffService } from './store-staff.service';
import { AdminAuthController } from './admin-auth.controller';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service';
import {
HqPermissionGuard,
HqPermissionsResolver,
} from '../../common/guards/hq-permission.guard';
import { CommonModule } from '../common/common.module';
@Module({
imports: [
IntegrationsModule,
forwardRef(() => CommonModule),
forwardRef(() => AnalyticsModule),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}),
],
controllers: [
UserAuthController,
ShopAuthController,
PartnerAuthController,
PartnerStaffController,
StoreStaffController,
UserProfileController,
UserAddressController,
AdminAuthController,
],
providers: [
AuthService,
UserAddressService,
PartnerStaffService,
StoreStaffService,
StoreMembershipService,
JwtAuthGuard,
PhoneVerifiedGuard,
OptionalJwtAuthGuard,
HqAuthGuard,
PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard,
ShopPrimaryGuard,
HqPermissionsResolver,
HqPermissionGuard,
],
exports: [
AuthService,
UserAddressService,
PartnerStaffService,
StoreStaffService,
StoreMembershipService,
JwtModule,
JwtAuthGuard,
PhoneVerifiedGuard,
OptionalJwtAuthGuard,
HqAuthGuard,
PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard,
ShopPrimaryGuard,
HqPermissionsResolver,
HqPermissionGuard,
],
})
export class IamModule {}
@@ -0,0 +1,45 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PartnerStaffService } from './partner-staff.service';
import {
CreatePartnerStaffDto,
SendPartnerStaffPhoneSmsDto,
UpdatePartnerStaffDto,
} from './dto/partner-staff.dto';
@Controller('partner/staff')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerStaffController {
constructor(private readonly staffService: PartnerStaffService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.staffService.listStaff(user.actorId);
}
@Post('send-phone-sms')
sendPhoneSms(@CurrentUser() user: AuthUser, @Body() dto: SendPartnerStaffPhoneSmsDto) {
return this.staffService.sendStaffPhoneSms(user, dto.phone);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
return this.staffService.createStaff(user, dto);
}
@Put(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdatePartnerStaffDto,
) {
return this.staffService.updateStaff(user, BigInt(id), dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.staffService.deleteStaff(user, BigInt(id));
}
}
@@ -0,0 +1,245 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { AuthService } from './auth.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(
private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
private readonly authService: AuthService,
) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const normalized = phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
if (existing) throw new BadRequestException('该手机号已被使用');
const masked = this.maskPhone(normalized);
try {
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
clientApp: ClientApp.PARTNER_H5,
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
status: 'success',
});
} catch (err) {
if (err instanceof BadRequestException) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
status: 'failed',
reason: err.message,
});
}
throw err;
}
return { ok: true, maskedPhone: masked };
}
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const smsCode = dto.smsCode.trim();
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
try {
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
} catch (err) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
phone: this.maskPhone(phone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
throw err;
}
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
const permissions =
dto.permissions && dto.permissions.length > 0
? dto.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name,
staffRole,
permissions,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
staffRole,
permissions,
status: account.status,
phoneVerified: true,
});
return this.toStaffItem(account);
}
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
const before = {
name: staff.name,
staffRole: staff.staffRole,
status: staff.status,
};
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole;
}
if (dto.permissions !== undefined) {
data.permissions = dto.permissions;
}
if (dto.status !== undefined) {
data.status = dto.status;
}
const updated = await this.prisma.partnerAccount.update({
where: { id: staff.id },
data,
});
const onlyRoleChange =
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
dto.name === undefined &&
dto.status === undefined;
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
const primaryId = parentAccountId;
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
before,
after: {
name: updated.name,
staffRole: updated.staffRole,
status: updated.status,
},
});
return this.toStaffItem(updated);
}
async deleteStaff(actor: AuthUser, staffId: bigint) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
name: staff.name,
phone: this.maskPhone(staff.phone),
staffRole: staff.staffRole,
status: staff.status,
});
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
private trackStaffEvent(
actor: AuthUser,
primaryAccountId: bigint,
eventName: string,
refId: bigint,
extraJson?: Record<string, unknown>,
) {
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
partnerAccountId: primaryAccountId,
eventName,
refType: 'PARTNER_ACCOUNT',
refId,
extraJson,
});
}
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
const staff = await this.prisma.partnerAccount.findFirst({
where: { id: staffId, parentAccountId },
});
if (!staff) throw new NotFoundException('子账号不存在');
return staff;
}
private toStaffItem(row: {
id: bigint;
name: string;
phone: string;
staffRole: string | null;
permissions?: unknown;
status: string;
lastLoginAt: Date | null;
}) {
return serializeBigInt({
id: row.id.toString(),
name: row.name,
phone: this.maskPhone(row.phone),
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
status: row.status,
lastLoginAt: row.lastLoginAt?.toISOString(),
});
}
private maskPhone(phone: string): string {
if (phone.length !== 11) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
}
}
@@ -0,0 +1,35 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { StoreStaffService } from './store-staff.service';
import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto';
@Controller('shop/staff')
@UseGuards(JwtAuthGuard)
export class StoreStaffController {
constructor(private readonly staffService: StoreStaffService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.staffService.listStaff(user.actorId);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreStaffDto) {
return this.staffService.createStaff(user, dto);
}
@Put(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateStoreStaffDto,
) {
return this.staffService.updateStaff(user, BigInt(id), dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.staffService.deleteStaff(user, BigInt(id));
}
}
@@ -0,0 +1,245 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
STORE_STAFF_DEFAULT_PERMISSIONS,
StoreStaffRole,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto';
@Injectable()
export class StoreStaffService {
constructor(
private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
) {}
async listStaff(parentAccountId: bigint) {
const parent = await this.assertPrimary(parentAccountId);
const rows = await this.prisma.storeAccount.findMany({
where: { parentAccountId: parent.id },
include: {
bindings: {
include: {
store: {
select: { id: true, name: true, status: true, district: true, address: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async createStaff(actor: AuthUser, dto: CreateStoreStaffDto) {
const parent = await this.assertPrimary(actor.actorId);
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds);
if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店');
const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER;
const permissions = dto.permissions?.length
? dto.permissions
: [...STORE_STAFF_DEFAULT_PERMISSIONS];
const account = await this.prisma.storeAccount.create({
data: {
phone,
name,
isPrimary: 0,
parentAccountId: parent.id,
staffRole,
permissions,
status: 'ACTIVE',
bindings: {
create: storeIds.map((storeId) => ({ storeId })),
},
},
include: {
bindings: {
include: {
store: {
select: { id: true, name: true, status: true, district: true, address: true },
},
},
},
},
});
this.trackStaffEvent(actor, parent.id, 'store_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
staffRole,
storeIds: storeIds.map(String),
});
return this.toStaffItem(account);
}
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdateStoreStaffDto) {
const parent = await this.assertPrimary(actor.actorId);
const staff = await this.assertStaffOwned(parent.id, staffId);
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole;
if (dto.permissions !== undefined) data.permissions = dto.permissions;
if (dto.status !== undefined) data.status = dto.status;
if (dto.storeIds !== undefined) {
const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds);
if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店');
await this.prisma.$transaction([
this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }),
this.prisma.storeAccountStore.createMany({
data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })),
}),
this.prisma.storeAccount.update({ where: { id: staff.id }, data }),
]);
} else if (Object.keys(data).length) {
await this.prisma.storeAccount.update({ where: { id: staff.id }, data });
}
const updated = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: staff.id },
include: {
bindings: {
include: {
store: {
select: { id: true, name: true, status: true, district: true, address: true },
},
},
},
},
});
this.trackStaffEvent(actor, parent.id, 'store_staff_update', staff.id, {
name: updated.name,
status: updated.status,
staffRole: updated.staffRole,
});
return this.toStaffItem(updated);
}
async deleteStaff(actor: AuthUser, staffId: bigint) {
const parent = await this.assertPrimary(actor.actorId);
const staff = await this.assertStaffOwned(parent.id, staffId);
this.trackStaffEvent(actor, parent.id, 'store_staff_delete', staff.id, {
name: staff.name,
phone: this.maskPhone(staff.phone),
});
await this.prisma.storeAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
private async assertPrimary(accountId: bigint) {
const account = await this.prisma.storeAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('门店账号不存在');
if (account.isPrimary !== 1) {
throw new ForbiddenException('仅主账号可管理子账号');
}
return account;
}
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
const staff = await this.prisma.storeAccount.findFirst({
where: { id: staffId, parentAccountId },
});
if (!staff) throw new NotFoundException('子账号不存在');
return staff;
}
/** Staff may only bind stores that the primary account itself is bound to. */
private async resolveOwnedStoreIds(primaryAccountId: bigint, storeIds: string[]) {
const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))];
const ids = unique.map((id) => BigInt(id));
const owned = await this.prisma.storeAccountStore.findMany({
where: { storeAccountId: primaryAccountId, storeId: { in: ids } },
select: { storeId: true },
});
if (owned.length !== ids.length) {
throw new BadRequestException('只能绑定主账号已管理的门店');
}
return ids;
}
private trackStaffEvent(
actor: AuthUser,
primaryAccountId: bigint,
eventName: string,
refId: bigint,
extraJson?: Record<string, unknown>,
) {
this.analytics.trackStoreOneSafe(actor.actorId, actor.clientApp, {
storeId: actor.storeId,
eventName,
refType: 'STORE_ACCOUNT',
refId,
extraJson: { primaryAccountId: primaryAccountId.toString(), ...extraJson },
});
}
private toStaffItem(row: {
id: bigint;
name: string;
phone: string;
staffRole: string | null;
permissions?: unknown;
status: string;
lastLoginAt: Date | null;
bindings: Array<{
store: {
id: bigint;
name: string;
status: string;
district: string;
address: string;
};
}>;
}) {
return serializeBigInt({
id: row.id.toString(),
name: row.name,
phone: this.maskPhone(row.phone),
staffRole: row.staffRole ?? StoreStaffRole.CASHIER,
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
status: row.status,
storeIds: row.bindings.map((b) => b.store.id.toString()),
stores: row.bindings.map((b) => ({
storeId: b.store.id.toString(),
name: b.store.name,
status: b.store.status,
district: b.store.district,
address: b.store.address,
})),
lastLoginAt: row.lastLoginAt?.toISOString(),
});
}
private maskPhone(phone: string): string {
if (phone.length !== 11) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
}
}
@@ -0,0 +1,34 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { UserAddressService } from './user-address.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('user/addresses')
@UseGuards(JwtAuthGuard)
export class UserAddressController {
constructor(private readonly addressService: UserAddressService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.addressService.list(user.actorId);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
return this.addressService.create(user.actorId, body);
}
@Put(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: Record<string, unknown>,
) {
return this.addressService.update(user.actorId, BigInt(id), body);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.addressService.remove(user.actorId, BigInt(id));
}
}
@@ -0,0 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@Injectable()
export class UserAddressService {
constructor(private readonly prisma: PrismaService) {}
async list(userId: bigint) {
const list = await this.prisma.userAddress.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
});
return serializeBigInt(list);
}
async normalizeDefaultAddress(userId: bigint) {
const defaults = await this.prisma.userAddress.findMany({
where: { userId, isDefault: 1 },
orderBy: { updatedAt: 'desc' },
});
if (defaults.length <= 1) return;
const keep = defaults[0];
await this.prisma.$transaction(async (tx) => {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
await tx.userAddress.update({ where: { id: keep.id }, data: { isDefault: 1 } });
});
}
async create(userId: bigint, body: Record<string, unknown>) {
const isDefault = body.isDefault ? 1 : 0;
const address = await this.prisma.$transaction(async (tx) => {
if (isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
return tx.userAddress.create({
data: {
userId,
receiverName: String(body.receiverName),
phone: String(body.phone),
province: String(body.province),
city: String(body.city),
district: String(body.district),
detail: String(body.detail),
isDefault,
},
});
});
return serializeBigInt(address);
}
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('地址不存在');
const address = await this.prisma.$transaction(async (tx) => {
if (body.isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
return tx.userAddress.update({
where: { id },
data: {
receiverName: body.receiverName ? String(body.receiverName) : undefined,
phone: body.phone ? String(body.phone) : undefined,
province: body.province ? String(body.province) : undefined,
city: body.city ? String(body.city) : undefined,
district: body.district ? String(body.district) : undefined,
detail: body.detail ? String(body.detail) : undefined,
isDefault: body.isDefault ? 1 : undefined,
},
});
});
return serializeBigInt(address);
}
async remove(userId: bigint, id: bigint) {
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('地址不存在');
await this.prisma.userAddress.delete({ where: { id } });
return { deleted: true };
}
}
@@ -0,0 +1,51 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminBenefitService } from './admin-benefit.service';
import { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
import { AdminBenefitGrantDto } from './dto/admin-mutate.dto';
@Controller('admin/benefit/coupons')
@UseGuards(HqAuthGuard)
export class AdminBenefitCouponsController {
constructor(private readonly service: AdminBenefitService) {}
@Get()
list(@Query() query: AdminBenefitCouponsQueryDto) {
return this.service.listCoupons(query);
}
@Post('grant')
@HqOperation({
action: HqOperationAction.BENEFIT_COUPON_GRANT,
refType: 'BENEFIT_COUPON',
refIdField: 'id',
includeBody: true,
})
grant(@Body() dto: AdminBenefitGrantDto) {
return this.service.grantCoupon(dto);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailCoupon(BigInt(id));
}
@Post(':id/void')
@HqOperation({ action: HqOperationAction.BENEFIT_COUPON_VOID, refType: 'BENEFIT_COUPON', refIdParam: 'id' })
voidCoupon(@Param('id') id: string) {
return this.service.voidCoupon(BigInt(id));
}
}
@Controller('admin/benefit/ledgers')
@UseGuards(HqAuthGuard)
export class AdminBenefitLedgersController {
constructor(private readonly service: AdminBenefitService) {}
@Get()
list(@Query() query: AdminBenefitLedgersQueryDto) {
return this.service.listLedgers(query);
}
}
@@ -0,0 +1,169 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat';
import { BenefitService } from '../benefit/benefit.service';
import { AdminRedeemService } from './admin-redeem.service';
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
import type { AdminBenefitGrantDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminBenefitService {
constructor(
private readonly prisma: PrismaService,
private readonly benefitService: BenefitService,
private readonly adminRedeemService: AdminRedeemService,
) {}
async listCoupons(query: AdminBenefitCouponsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.BenefitCouponWhereInput = {};
if (query.couponNo) where.couponNo = { contains: query.couponNo };
if (query.userId) where.userId = BigInt(query.userId);
if (query.status) where.status = query.status as Prisma.EnumBenefitCouponStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.benefitCoupon.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true } },
},
}),
this.prisma.benefitCoupon.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailCoupon(id: bigint) {
const coupon = await this.prisma.benefitCoupon.findUnique({
where: { id },
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
},
});
if (!coupon) throw new NotFoundException('权益券不存在');
const [ledgers, redeemTrace] = await Promise.all([
this.prisma.commonEvent.findMany({
where: benefitLedgerWhere(undefined, id),
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.adminRedeemService.buildCouponRedeemTrace(coupon),
]);
return serializeBigInt({
...coupon,
ledgers,
redeemSummary: redeemTrace.redeemSummary,
redeemRecords: redeemTrace.redeemRecords,
});
}
async voidCoupon(id: bigint) {
const coupon = await this.prisma.benefitCoupon.findUnique({ where: { id } });
if (!coupon) throw new NotFoundException('权益券不存在');
if (coupon.status === 'VOID') throw new BadRequestException('权益券已作废');
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.benefitCoupon.update({
where: { id },
data: { status: 'VOID', balance: 0 },
});
if (Number(coupon.balance) > 0) {
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'ADJUST',
amount: -Number(coupon.balance),
balanceAfter: 0,
refType: 'ADMIN_VOID',
remark: 'HQ 手动作废',
}),
});
}
return row;
});
return serializeBigInt(updated);
}
async grantCoupon(dto: AdminBenefitGrantDto) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的用户手机号');
}
const user = await this.prisma.user.findFirst({
where: { phone, status: 1, mergedIntoUserId: null },
select: { id: true, userNo: true, phone: true, nickname: true },
});
if (!user) {
throw new NotFoundException('未找到该手机号对应的用户');
}
const coupon = await this.benefitService.grantManual({
userId: user.id,
amount: dto.amount,
remark: dto.remark,
});
return serializeBigInt({
...coupon,
user,
});
}
async listLedgers(query: AdminBenefitLedgersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonEventWhereInput = {
eventType: 'BENEFIT_LEDGER',
...(query.userId ? { actorType: 'USER', actorId: BigInt(query.userId) } : {}),
...(query.couponId ? { param2: BigInt(query.couponId).toString() } : {}),
...(query.type ? { param1: query.type } : {}),
};
const [items, total] = await Promise.all([
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonEvent.count({ where }),
]);
const userIds = [...new Set(items.map((i) => i.actorId).filter(Boolean))] as bigint[];
const couponIds = [...new Set(items.map((i) => i.param2).filter(Boolean))].map((id) => BigInt(id!));
const [users, coupons] = await Promise.all([
userIds.length
? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, userNo: true } })
: Promise.resolve([] as { id: bigint; userNo: string | null }[]),
couponIds.length
? this.prisma.benefitCoupon.findMany({ where: { id: { in: couponIds } }, select: { id: true, couponNo: true } })
: Promise.resolve([] as { id: bigint; couponNo: string }[]),
]);
const userMap = new Map(users.map((u) => [u.id.toString(), u] as const));
const couponMap = new Map(coupons.map((c) => [c.id.toString(), c] as const));
return serializeBigInt({
items: items.map((e) =>
mapBenefitLedgerCompat(
e,
e.actorId ? userMap.get(e.actorId.toString()) : null,
e.param2 ? couponMap.get(e.param2) : null,
),
),
total,
page,
pageSize,
});
}
}
@@ -0,0 +1,71 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsString, MinLength } from 'class-validator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
class DeleteCityDto {
@IsString()
@MinLength(1)
confirmName!: string;
}
@Controller('admin/cities')
@UseGuards(HqAuthGuard)
export class AdminCitiesController {
constructor(private readonly service: AdminCitiesService) {}
@Get()
list(@Query() query: AdminCitiesQueryDto) {
return this.service.list(query);
}
@Get(':id/delete-preview')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('cities_delete')
deletePreview(@Param('id') id: string) {
return this.service.deletePreview(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@HqOperation({ action: HqOperationAction.CITY_CREATE, refType: 'CITY', refIdField: 'id', includeBody: true })
create(@Body() dto: CreateCityDto) {
return this.service.create(dto);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.CITY_UPDATE,
refType: 'CITY',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('cities_delete')
@HqOperation({
action: HqOperationAction.CITY_DELETE,
refType: 'CITY',
refIdParam: 'id',
includeBody: true,
})
remove(@Param('id') id: string, @Body() dto: DeleteCityDto) {
return this.service.deleteCity(BigInt(id), dto.confirmName);
}
}
@@ -0,0 +1,385 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminCitiesService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonCityWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
if (query.partnerId) {
where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } };
}
const [items, total] = await Promise.all([
this.prisma.commonCity.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partnerAccounts: {
where: { isPrimary: 1 },
select: { id: true, companyName: true, scopeType: true, bindingStatus: true },
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, orders: true, partnerAccounts: true, warehouses: true } },
},
}),
this.prisma.commonCity.count({ where }),
]);
return serializeBigInt({
items: items.map((c) => ({
...c,
partnerBindings: c.partnerAccounts.map((bp) => ({
id: bp.id.toString(),
partnerAccountId: bp.id.toString(),
partnerCompanyName: bp.companyName,
scopeType: bp.scopeType,
status: bp.bindingStatus,
})),
partnerAccounts: undefined,
storeCount: c._count.stores,
orderCount: c._count.orders,
partnerBindingCount: c.partnerAccounts.length,
warehouseCount: c._count.warehouses,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const city = await this.prisma.commonCity.findUnique({
where: { id },
include: {
warehouses: {
include: { partnerAccount: { select: { id: true, companyName: true } } },
orderBy: { createdAt: 'desc' },
},
_count: { select: { stores: true, orders: true } },
},
});
if (!city) throw new NotFoundException('开城城市不存在');
const cityPartners = await this.partnerCityService.listByCity(id);
return serializeBigInt({
...city,
// Decimal 经 JSON 会变成字符串,显式 Number 避免前端 `"0.05" + 1e-9` 字符串拼接误判
maxPartnerCommissionRate:
city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null,
cityPartners,
storeCount: city._count.stores,
orderCount: city._count.orders,
_count: undefined,
});
}
async create(dto: CreateCityDto) {
const exists = await this.prisma.commonCity.findUnique({ where: { code: dto.code } });
if (exists) throw new BadRequestException('城市编码已存在');
const city = await this.prisma.commonCity.create({
data: {
code: dto.code,
name: dto.name,
province: dto.province,
status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED',
},
});
return serializeBigInt(city);
}
async update(id: bigint, dto: UpdateCityDto) {
if (dto.maxPartnerCommissionRate !== undefined) {
const maxRate = resolveMaxPartnerCommissionRate(dto.maxPartnerCommissionRate);
const partners = await this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 1 },
select: {
companyName: true,
orderCommissionRate: true,
redeemCommissionRate: true,
},
});
for (const partner of partners) {
const check = validatePartnerCommissionRates(
Number(partner.orderCommissionRate ?? 0),
Number(partner.redeemCommissionRate ?? 0.03),
maxRate,
);
if (!check.ok) {
throw new BadRequestException(
`无法保存:合伙人「${partner.companyName ?? '—'}${check.message}`,
);
}
}
}
const city = await this.prisma.commonCity.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.province !== undefined ? { province: dto.province } : {}),
...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
...(dto.maxPartnerCommissionRate !== undefined
? { maxPartnerCommissionRate: dto.maxPartnerCommissionRate }
: {}),
},
});
return serializeBigInt(city);
}
/** 删除前预览:列出城市下合伙人(含子账号)与门店,以及不可删阻断项 */
async deletePreview(id: bigint) {
const city = await this.prisma.commonCity.findUnique({
where: { id },
select: { id: true, code: true, name: true, province: true, status: true },
});
if (!city) throw new NotFoundException('开城城市不存在');
const [primaries, staff, stores, warehouses, orderCount] = await Promise.all([
this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 1 },
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
bindingStatus: true,
scopeType: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 0 },
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
parentAccountId: true,
staffRole: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.store.findMany({
where: { cityId: id },
select: {
id: true,
name: true,
phone: true,
status: true,
auditStatus: true,
address: true,
partnerAccountId: true,
partnerAccount: { select: { companyName: true, phone: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.cityWarehouse.findMany({
where: { cityId: id },
select: { id: true, name: true, status: true, address: true },
orderBy: { createdAt: 'desc' },
}),
this.prisma.order.count({ where: { cityId: id } }),
]);
const storeIds = stores.map((s) => s.id);
const partnerIds = [...primaries, ...staff].map((p) => p.id);
const [redeemCount, partnerBillCount] = await Promise.all([
storeIds.length
? this.prisma.redeemRecord.count({ where: { storeId: { in: storeIds } } })
: Promise.resolve(0),
partnerIds.length
? this.prisma.partnerBill.count({ where: { partnerAccountId: { in: partnerIds } } })
: Promise.resolve(0),
]);
const warnings: string[] = [];
const blockers: string[] = [];
if (orderCount > 0) blockers.push(`该城市下已有 ${orderCount} 笔订单,无法删除`);
if (redeemCount > 0) warnings.push(`门店核销记录 ${redeemCount} 笔将删除,并回滚对应权益券余额`);
if (partnerBillCount > 0) warnings.push(`合伙人账单 ${partnerBillCount} 条将一并删除`);
if (stores.length) warnings.push(`将删除 ${stores.length} 家门店及其门店账号绑定`);
if (primaries.length || staff.length) {
warnings.push(`将删除 ${primaries.length} 个合伙人主账号、${staff.length} 个子账号`);
}
if (warehouses.length) warnings.push(`将删除 ${warehouses.length} 个城市仓库`);
return serializeBigInt({
city,
canDelete: blockers.length === 0,
blockers,
warnings,
summary: {
primaryPartnerCount: primaries.length,
staffCount: staff.length,
storeCount: stores.length,
warehouseCount: warehouses.length,
orderCount,
redeemCount,
partnerBillCount,
},
partners: primaries.map((p) => ({
...p,
staff: staff.filter((s) => s.parentAccountId === p.id),
})),
orphanStaff: staff.filter(
(s) => !s.parentAccountId || !primaries.some((p) => p.id === s.parentAccountId),
),
stores,
warehouses,
});
}
async deleteCity(id: bigint, confirmName: string) {
const preview = await this.deletePreview(id);
if (!preview.canDelete) {
throw new BadRequestException(preview.blockers.join('') || '当前城市不可删除');
}
const expected = String(preview.city.name || '').trim();
if (!confirmName?.trim() || confirmName.trim() !== expected) {
throw new BadRequestException(`请输入城市名称「${expected}」以确认删除`);
}
await this.prisma.$transaction(async (tx) => {
const storeIds = (preview.stores as Array<{ id: string | number | bigint }>).map((s) =>
BigInt(s.id),
);
const partnerIdSet = new Set<string>();
for (const p of preview.partners as Array<{
id: string | number | bigint;
staff?: Array<{ id: string | number | bigint }>;
}>) {
partnerIdSet.add(String(p.id));
for (const s of p.staff ?? []) partnerIdSet.add(String(s.id));
}
for (const s of preview.orphanStaff as Array<{ id: string | number | bigint }>) {
partnerIdSet.add(String(s.id));
}
const uniquePartnerIds = [...partnerIdSet].map(BigInt);
if (storeIds.length) {
await this.purgeStoresInTx(tx, storeIds);
}
if (uniquePartnerIds.length) {
await tx.partnerBill.deleteMany({ where: { partnerAccountId: { in: uniquePartnerIds } } });
await tx.$executeRaw`
DELETE FROM log_partner_analytics WHERE partner_account_id IN (${Prisma.join(uniquePartnerIds)})
`;
await tx.partnerAccount.updateMany({
where: { id: { in: uniquePartnerIds } },
data: { managedWarehouseId: null },
});
await tx.cityWarehouse.updateMany({
where: { cityId: id },
data: { partnerAccountId: null },
});
await tx.partnerAccount.deleteMany({
where: { id: { in: uniquePartnerIds }, isPrimary: 0 },
});
await tx.partnerAccount.deleteMany({
where: { id: { in: uniquePartnerIds }, isPrimary: 1 },
});
}
await tx.cityWarehouse.deleteMany({ where: { cityId: id } });
await tx.commonCity.delete({ where: { id } });
});
return { ok: true, id: id.toString(), name: preview.city.name };
}
/** 事务内清除门店及核销/结算/绑定(回滚权益券核销额) */
private async purgeStoresInTx(tx: Prisma.TransactionClient, storeIds: bigint[]) {
const redeems = await tx.redeemRecord.findMany({
where: { storeId: { in: storeIds } },
include: { allocations: true },
});
const restoreMap = new Map<string, Prisma.Decimal>();
for (const r of redeems) {
if (r.allocations.length) {
for (const a of r.allocations) {
const key = a.couponId.toString();
const prev = restoreMap.get(key) ?? new Prisma.Decimal(0);
restoreMap.set(key, prev.add(a.amount));
}
} else {
const key = r.couponId.toString();
const prev = restoreMap.get(key) ?? new Prisma.Decimal(0);
restoreMap.set(key, prev.add(r.amount));
}
}
for (const [couponId, amount] of restoreMap) {
const coupon = await tx.benefitCoupon.findUnique({ where: { id: BigInt(couponId) } });
if (!coupon) continue;
const used = new Prisma.Decimal(coupon.usedAmount).sub(amount);
const balance = new Prisma.Decimal(coupon.balance).add(amount);
const nextUsed = used.lt(0) ? new Prisma.Decimal(0) : used;
const nextBalance = Prisma.Decimal.min(balance, coupon.totalAmount);
await tx.benefitCoupon.update({
where: { id: BigInt(couponId) },
data: {
usedAmount: nextUsed,
balance: nextBalance,
status: nextBalance.gt(0) ? 'ACTIVE' : coupon.status,
version: { increment: 1 },
},
});
}
const redeemIds = redeems.map((r) => r.id);
await tx.storePayout.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.storeRating.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.redeemPendingRecord.deleteMany({ where: { storeId: { in: storeIds } } });
if (redeemIds.length) {
await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
await tx.$executeRaw`DELETE FROM log_store_analytics WHERE store_id IN (${Prisma.join(storeIds)})`;
const bindings = await tx.storeAccountStore.findMany({
where: { storeId: { in: storeIds } },
select: { storeAccountId: true },
});
const accountIds = [...new Set(bindings.map((b) => b.storeAccountId.toString()))].map(BigInt);
await tx.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
const orphanAccountIds: bigint[] = [];
for (const aid of accountIds) {
const other = await tx.storeAccountStore.count({
where: { storeAccountId: aid, storeId: { notIn: storeIds } },
});
if (other === 0) orphanAccountIds.push(aid);
}
if (orphanAccountIds.length) {
await tx.storeAccount.deleteMany({ where: { parentAccountId: { in: orphanAccountIds } } });
await tx.storeAccount.deleteMany({ where: { id: { in: orphanAccountIds } } });
}
await tx.store.updateMany({ where: { id: { in: storeIds } }, data: { coverResourceId: null } });
await tx.store.deleteMany({ where: { id: { in: storeIds } } });
}
}
@@ -0,0 +1,110 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
import type {
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
} from '@prisma/client';
function mapWarehouseFulfillment(dto: CreateCityWarehouseDto | UpdateCityWarehouseDto) {
return {
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
fulfillmentProviderId: dto.fulfillmentProviderId ? BigInt(dto.fulfillmentProviderId) : undefined,
manualCarrierLabel: dto.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: dto.manualQueryUrlTemplate ?? undefined,
lng: dto.lng ?? undefined,
lat: dto.lat ?? undefined,
};
}
@Controller('admin/cities/:cityId/warehouses')
@UseGuards(HqAuthGuard)
export class AdminCityWarehousesController {
constructor(private readonly service: CityWarehouseService) {}
@Get()
list(@Param('cityId') cityId: string) {
return this.service.listByCity(BigInt(cityId));
}
@Post()
@HqOperation({
action: HqOperationAction.WAREHOUSE_CREATE,
refType: 'WAREHOUSE',
refIdField: 'id',
includeBody: true,
})
create(@Param('cityId') cityId: string, @Body() dto: CreateCityWarehouseDto) {
return this.service.create(BigInt(cityId), {
name: dto.name,
address: dto.address,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
managerType: dto.managerType as WarehouseManagerType,
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
status: dto.status as WarehouseStatus | undefined,
...mapWarehouseFulfillment(dto),
});
}
}
@Controller('admin/city-warehouses')
@UseGuards(HqAuthGuard)
export class AdminCityWarehouseMutationsController {
constructor(private readonly service: CityWarehouseService) {}
@Get()
listAll(@Query() query: AdminCityWarehousesQueryDto) {
return this.service.listAll(query);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'WAREHOUSE',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateCityWarehouseDto) {
return this.service.update(BigInt(id), {
name: dto.name,
address: dto.address,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
managerType: dto.managerType as WarehouseManagerType | undefined,
partnerAccountId:
dto.partnerAccountId === null
? null
: dto.partnerAccountId
? BigInt(dto.partnerAccountId)
: undefined,
status: dto.status as WarehouseStatus | undefined,
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
fulfillmentProviderId:
dto.fulfillmentProviderId === null
? null
: dto.fulfillmentProviderId
? BigInt(dto.fulfillmentProviderId)
: undefined,
manualCarrierLabel: dto.manualCarrierLabel,
manualQueryUrlTemplate: dto.manualQueryUrlTemplate,
lng: dto.lng,
lat: dto.lat,
});
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_DELETE,
refType: 'WAREHOUSE',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,27 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminDashboardService } from './admin-dashboard.service';
import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
@Controller('admin/dashboard')
@UseGuards(HqAuthGuard)
export class AdminDashboardController {
constructor(private readonly dashboardService: AdminDashboardService) {}
@Get('stats')
stats() {
return this.dashboardService.getStats();
}
@Get('analytics')
analytics(@Query() query: AdminDashboardAnalyticsQueryDto) {
return this.dashboardService.getAnalytics(query);
}
@Get('version')
@UseGuards(SuperAdminGuard)
version() {
return this.dashboardService.getLatestVersion();
}
}
@@ -0,0 +1,576 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function endOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
}
function parseYmd(s: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(`${s}T00:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
function formatYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function eachDate(from: Date, to: Date): string[] {
const out: string[] = [];
const cur = startOfDay(from);
const end = startOfDay(to);
while (cur <= end) {
out.push(formatYmd(cur));
cur.setDate(cur.getDate() + 1);
}
return out;
}
function num(v: Prisma.Decimal | number | string | null | undefined): number {
if (v == null) return 0;
return typeof v === 'number' ? v : Number(v);
}
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
const day = appliedAt.getDay();
if (day === 0 || day === 6) return false;
const deadline = new Date(
appliedAt.getFullYear(),
appliedAt.getMonth(),
appliedAt.getDate(),
18,
0,
0,
0,
);
return now.getTime() > deadline.getTime();
}
@Injectable()
export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats() {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const [
usersTotal,
guestUsers,
verifiedUsers,
mergedUsers,
ordersToday,
ordersByStatus,
storesTotal,
partnersTotal,
redeemToday,
deliveriesTotal,
pendingPayouts,
pendingBills,
pendingPartnerDraftBills,
openTickets,
pendingWithdrawRows,
] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
this.prisma.user.count({
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null },
}),
this.prisma.user.count({
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } },
}),
this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }),
this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.order.groupBy({
by: ['status'],
_count: { status: true },
}),
this.prisma.store.count(),
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
this.prisma.storeWithdrawRequest.findMany({
where: { status: 'PENDING_REVIEW' },
select: { appliedAt: true },
}),
]);
const now = new Date();
const pendingStoreWithdrawals = pendingWithdrawRows.length;
const overdueStoreWithdrawals = pendingWithdrawRows.filter((r) =>
isWithdrawOverdue(r.appliedAt, now),
).length;
return {
usersTotal,
guestUsers,
verifiedUsers,
mergedUsers,
ordersToday,
storesTotal,
partnersTotal,
redeemToday,
deliveriesTotal,
pendingPayouts,
pendingBills,
pendingPartnerDraftBills,
openTickets,
pendingStoreWithdrawals,
overdueStoreWithdrawals,
ordersByStatus: ordersByStatus.map((row) => ({
status: row.status,
count: row._count.status,
})),
};
}
async getLatestVersion() {
const row = await this.prisma.systemVersion.findFirst({
orderBy: { deployedAt: 'desc' },
});
if (!row) return null;
return {
id: row.id.toString(),
gitTag: row.gitTag,
commitId: row.commitId,
commitMessage: row.commitMessage,
branch: row.branch,
deployedBy: row.deployedBy,
deployedAt: row.deployedAt.toISOString(),
};
}
async getAnalytics(query: AdminDashboardAnalyticsQueryDto) {
const today = startOfDay(new Date());
const defaultFrom = new Date(today);
defaultFrom.setDate(defaultFrom.getDate() - 29);
const from =
(query.dateFrom ? parseYmd(query.dateFrom) : null) ?? defaultFrom;
const to =
(query.dateTo ? parseYmd(query.dateTo) : null) ?? today;
const rangeStart = startOfDay(from <= to ? from : to);
const rangeEnd = endOfDay(from <= to ? to : from);
let filterCityCode: string | null | undefined;
let filterCityId: bigint | null | undefined;
if (query.cityId === 'none') {
filterCityCode = null;
filterCityId = null;
} else if (query.cityId) {
const city = await this.prisma.commonCity.findUnique({
where: { id: BigInt(query.cityId) },
select: { id: true, code: true },
});
if (city) {
filterCityCode = city.code;
filterCityId = city.id;
}
}
const filterPromoNone = query.promoCodeId === 'none';
const filterPromoId =
query.promoCodeId && query.promoCodeId !== 'none'
? BigInt(query.promoCodeId)
: undefined;
const filterPartnerId = query.partnerAccountId
? BigInt(query.partnerAccountId)
: undefined;
const userWhere: Prisma.UserWhereInput = {
status: 1,
mergedIntoUserId: null,
createdAt: { gte: rangeStart, lte: rangeEnd },
};
if (filterCityCode === null) {
userWhere.OR = [
{ cityPreference: null },
{ cityPreference: { selectedCityCode: null } },
];
} else if (filterCityCode) {
userWhere.cityPreference = { selectedCityCode: filterCityCode };
}
if (filterPromoNone) {
userWhere.promoTouch = { is: null };
} else if (filterPromoId !== undefined) {
userWhere.promoTouch = { promoCodeId: filterPromoId };
}
const orderWhere: Prisma.OrderWhereInput = {
createdAt: { gte: rangeStart, lte: rangeEnd },
};
if (filterCityId !== undefined && filterCityId !== null) {
orderWhere.cityId = filterCityId;
}
if (filterPromoNone) {
orderWhere.promoCodeId = null;
} else if (filterPromoId !== undefined) {
orderWhere.promoCodeId = filterPromoId;
}
const partnerWhere: Prisma.PartnerAccountWhereInput = {
isPrimary: 1,
createdAt: { gte: rangeStart, lte: rangeEnd },
};
if (filterCityId === null) {
partnerWhere.cityId = null;
} else if (filterCityId !== undefined) {
partnerWhere.cityId = filterCityId;
}
if (filterPartnerId !== undefined) {
partnerWhere.id = filterPartnerId;
}
const storeWhere: Prisma.StoreWhereInput = {
createdAt: { gte: rangeStart, lte: rangeEnd },
};
if (filterCityId === null) {
// 门店必有 cityId
storeWhere.id = { in: [] };
} else if (filterCityId !== undefined) {
storeWhere.cityId = filterCityId;
}
if (filterPartnerId !== undefined) {
storeWhere.partnerAccountId = filterPartnerId;
}
const redeemWhere: Prisma.RedeemRecordWhereInput = {
createdAt: { gte: rangeStart, lte: rangeEnd },
};
if (filterCityId === null) {
redeemWhere.id = { in: [] };
} else {
const storeFilter: Prisma.StoreWhereInput = {};
if (filterCityId !== undefined) storeFilter.cityId = filterCityId;
if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId;
if (Object.keys(storeFilter).length) {
redeemWhere.store = storeFilter;
}
}
const skipOrders = filterCityId === null;
const [users, orders, partners, stores, redeems, cities, promos, partnerNames] =
await Promise.all([
this.prisma.user.findMany({
where: userWhere,
select: {
id: true,
createdAt: true,
cityPreference: { select: { selectedCityCode: true } },
promoTouch: { select: { promoCodeId: true } },
},
}),
skipOrders
? Promise.resolve([])
: this.prisma.order.findMany({
where: orderWhere,
select: {
id: true,
userId: true,
createdAt: true,
cityId: true,
promoCodeId: true,
payStatus: true,
},
}),
this.prisma.partnerAccount.findMany({
where: partnerWhere,
select: {
id: true,
createdAt: true,
cityId: true,
companyName: true,
name: true,
},
}),
this.prisma.store.findMany({
where: storeWhere,
select: {
id: true,
createdAt: true,
cityId: true,
partnerAccountId: true,
},
}),
this.prisma.redeemRecord.findMany({
where: redeemWhere,
select: {
id: true,
createdAt: true,
amount: true,
settleAmount: true,
store: { select: { cityId: true, partnerAccountId: true } },
},
}),
this.prisma.commonCity.findMany({
select: { id: true, code: true, name: true },
}),
this.prisma.commonPromoCode.findMany({
select: { id: true, code: true, name: true },
}),
this.prisma.partnerAccount.findMany({
where: { isPrimary: 1 },
select: { id: true, companyName: true, name: true },
}),
]);
const cityByCode = new Map(cities.map((c) => [c.code, c]));
const cityById = new Map(cities.map((c) => [c.id.toString(), c]));
const promoById = new Map(promos.map((p) => [p.id.toString(), p]));
const partnerLabel = new Map(
partnerNames.map((p) => [
p.id.toString(),
p.companyName || p.name || `合伙人#${p.id}`,
]),
);
const dateKeys = eachDate(rangeStart, rangeEnd);
type DateBucket = {
date: string;
users: number;
orders: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
};
const byDateMap = new Map<string, DateBucket>(
dateKeys.map((d) => [
d,
{ date: d, users: 0, orders: 0, partners: 0, stores: 0, redeems: 0, redeemAmount: 0 },
]),
);
type CityBucket = {
cityId: string;
cityName: string;
users: number;
orders: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
};
const byCityMap = new Map<string, CityBucket>();
type PromoBucket = {
promoCodeId: string | null;
code: string;
name: string;
users: number;
orders: number;
};
const byPromoMap = new Map<string, PromoBucket>();
type PartnerBucket = {
partnerAccountId: string;
companyName: string;
stores: number;
redeems: number;
redeemAmount: number;
};
const byPartnerMap = new Map<string, PartnerBucket>();
const ensureCity = (key: string, cityId: string, cityName: string) => {
let b = byCityMap.get(key);
if (!b) {
b = {
cityId,
cityName,
users: 0,
orders: 0,
partners: 0,
stores: 0,
redeems: 0,
redeemAmount: 0,
};
byCityMap.set(key, b);
}
return b;
};
const ensurePromo = (
key: string,
promoCodeId: string | null,
code: string,
name: string,
) => {
let b = byPromoMap.get(key);
if (!b) {
b = { promoCodeId, code, name, users: 0, orders: 0 };
byPromoMap.set(key, b);
}
return b;
};
const ensurePartner = (key: string, companyName: string) => {
let b = byPartnerMap.get(key);
if (!b) {
b = {
partnerAccountId: key,
companyName,
stores: 0,
redeems: 0,
redeemAmount: 0,
};
byPartnerMap.set(key, b);
}
return b;
};
for (const u of users) {
const d = formatYmd(u.createdAt);
const day = byDateMap.get(d);
if (day) day.users += 1;
const code = u.cityPreference?.selectedCityCode ?? null;
if (code && cityByCode.has(code)) {
const city = cityByCode.get(code)!;
ensureCity(city.id.toString(), city.id.toString(), city.name).users += 1;
} else {
ensureCity('none', 'none', '未选城').users += 1;
}
const pid = u.promoTouch?.promoCodeId?.toString() ?? null;
if (pid && promoById.has(pid)) {
const p = promoById.get(pid)!;
ensurePromo(pid, pid, p.code, p.name).users += 1;
} else {
ensurePromo('none', null, 'ORGANIC', '自然量').users += 1;
}
}
const payingUserIds = new Set<string>();
for (const o of orders) {
const d = formatYmd(o.createdAt);
const day = byDateMap.get(d);
if (day) day.orders += 1;
const cid = o.cityId.toString();
const city = cityById.get(cid);
ensureCity(cid, cid, city?.name ?? `城市#${cid}`).orders += 1;
const pid = o.promoCodeId?.toString() ?? null;
if (pid && promoById.has(pid)) {
const p = promoById.get(pid)!;
ensurePromo(pid, pid, p.code, p.name).orders += 1;
} else {
ensurePromo('none', null, 'NONE', '无推广码').orders += 1;
}
if (o.payStatus === 'PAID') {
payingUserIds.add(o.userId.toString());
}
}
for (const p of partners) {
const d = formatYmd(p.createdAt);
const day = byDateMap.get(d);
if (day) day.partners += 1;
if (p.cityId) {
const cid = p.cityId.toString();
const city = cityById.get(cid);
ensureCity(cid, cid, city?.name ?? `城市#${cid}`).partners += 1;
} else {
ensureCity('none', 'none', '未绑定城市').partners += 1;
}
const key = p.id.toString();
ensurePartner(key, p.companyName || p.name || `合伙人#${key}`);
}
for (const s of stores) {
const d = formatYmd(s.createdAt);
const day = byDateMap.get(d);
if (day) day.stores += 1;
const cid = s.cityId.toString();
const city = cityById.get(cid);
ensureCity(cid, cid, city?.name ?? `城市#${cid}`).stores += 1;
const pid = s.partnerAccountId.toString();
ensurePartner(pid, partnerLabel.get(pid) || `合伙人#${pid}`).stores += 1;
}
let redeemAmountTotal = 0;
for (const r of redeems) {
const amount = num(r.amount);
redeemAmountTotal += amount;
const d = formatYmd(r.createdAt);
const day = byDateMap.get(d);
if (day) {
day.redeems += 1;
day.redeemAmount += amount;
}
const cid = r.store.cityId.toString();
const city = cityById.get(cid);
const cityBucket = ensureCity(cid, cid, city?.name ?? `城市#${cid}`);
cityBucket.redeems += 1;
cityBucket.redeemAmount += amount;
const pid = r.store.partnerAccountId.toString();
const partnerBucket = ensurePartner(
pid,
partnerLabel.get(pid) || `合伙人#${pid}`,
);
partnerBucket.redeems += 1;
partnerBucket.redeemAmount += amount;
}
const byCity = [...byCityMap.values()].sort(
(a, b) =>
b.users + b.orders + b.partners + b.stores + b.redeems -
(a.users + a.orders + a.partners + a.stores + a.redeems),
);
const byPromo = [...byPromoMap.values()].sort(
(a, b) => b.users + b.orders - (a.users + a.orders),
);
const byPartner = [...byPartnerMap.values()].sort(
(a, b) => b.stores + b.redeems - (a.stores + a.redeems),
);
return {
summary: {
users: users.length,
orders: orders.length,
payingUsers: payingUserIds.size,
partners: partners.length,
stores: stores.length,
redeems: redeems.length,
redeemAmount: Math.round(redeemAmountTotal * 100) / 100,
},
byDate: dateKeys.map((d) => {
const row = byDateMap.get(d)!;
return {
...row,
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
};
}),
byCity: byCity.map((row) => ({
...row,
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
})),
byPromo,
byPartner: byPartner.map((row) => ({
...row,
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
})),
};
}
}
@@ -0,0 +1,22 @@
import { Controller, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminDeployService } from './admin-deploy.service';
@Controller('admin/deploy')
@UseGuards(HqAuthGuard, SuperAdminGuard)
export class AdminDeployController {
constructor(private readonly deployService: AdminDeployService) {}
@Post('trigger')
@HqOperation({
action: HqOperationAction.DEPLOY_TRIGGER,
refType: 'DEPLOY',
batch: true,
})
trigger() {
return this.deployService.triggerDeploy();
}
}
@@ -0,0 +1,49 @@
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
@Injectable()
export class AdminDeployService {
async triggerDeploy() {
const url = (process.env.DEPLOY_WEBHOOK_URL || 'http://127.0.0.1:8095/deploy').trim();
const secret = (process.env.DEPLOY_WEBHOOK_SECRET || '').trim();
if (!secret) {
throw new ServiceUnavailableException('未配置 DEPLOY_WEBHOOK_SECRET,无法触发发布');
}
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Deploy-Token': secret,
},
body: JSON.stringify({ source: 'admin' }),
});
} catch (err) {
throw new ServiceUnavailableException(
`无法连接部署 webhook${err instanceof Error ? err.message : 'network error'}`,
);
}
const text = await res.text();
let data: { ok?: boolean; accepted?: boolean; started?: boolean; message?: string; skipped?: boolean } = {};
try {
data = text ? (JSON.parse(text) as typeof data) : {};
} catch {
throw new ServiceUnavailableException(`部署 webhook 返回非 JSONHTTP ${res.status}`);
}
if (res.status === 403) {
throw new BadRequestException(data.message || '部署 webhook 鉴权失败');
}
if (!res.ok && res.status !== 202) {
throw new ServiceUnavailableException(data.message || `部署 webhook 失败(HTTP ${res.status}`);
}
return {
accepted: true,
started: data.started !== false && !data.skipped,
message: data.message || (data.started === false ? 'deploy debounced' : 'deploy started'),
};
}
}
@@ -0,0 +1,36 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import type { EventType } from '@prisma/client';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminDomainEventsService } from './admin-domain-events.service';
@Controller('admin/logs/domain-events')
@UseGuards(HqAuthGuard)
export class AdminDomainEventsController {
constructor(private readonly service: AdminDomainEventsService) {}
@Get()
list(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('eventType') eventType?: EventType,
@Query('refType') refType?: string,
@Query('refId') refId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.service.list({
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
eventType,
refType,
refId,
from,
to,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,59 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { EventType, Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
const DOMAIN_EVENT_TYPES: EventType[] = [
'ORDER_STATUS',
'BENEFIT_LEDGER',
'STORE_AUDIT',
'TICKET_COLLAB',
'PROMO_TOUCH',
];
@Injectable()
export class AdminDomainEventsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: {
page?: number;
pageSize?: number;
eventType?: EventType;
refType?: string;
refId?: string;
from?: string;
to?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonEventWhereInput = {
eventType: query.eventType ?? { in: DOMAIN_EVENT_TYPES },
};
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
if (query.from || query.to) {
where.createdAt = {};
if (query.from) where.createdAt.gte = new Date(query.from);
if (query.to) where.createdAt.lte = new Date(query.to);
}
const [items, total] = await Promise.all([
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonEvent.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const row = await this.prisma.commonEvent.findUnique({ where: { id } });
if (!row || !DOMAIN_EVENT_TYPES.includes(row.eventType)) {
throw new NotFoundException('领域事件不存在');
}
return serializeBigInt(row);
}
}
@@ -0,0 +1,92 @@
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import {
CreateFulfillmentProviderDto,
RechargeFulfillmentProviderDto,
UpdateFulfillmentProviderDto,
} from './dto/admin-mutate.dto';
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
@Controller('admin/fulfillment-providers')
@UseGuards(HqAuthGuard)
export class AdminFulfillmentProvidersController {
constructor(private readonly service: FulfillmentProviderService) {}
@Get()
list() {
return this.service.listAll();
}
@Get('active-api')
listActiveApi() {
return this.service.listActiveApiProviders();
}
@Post()
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'FULFILLMENT_PROVIDER',
refIdField: 'id',
includeBody: true,
})
create(@Body() dto: CreateFulfillmentProviderDto) {
return this.service.create({
code: dto.code,
name: dto.name,
type: dto.type as FulfillmentProviderType,
status: dto.status as FulfillmentProviderStatus | undefined,
configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.getById(BigInt(id));
}
@Put(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateFulfillmentProviderDto) {
return this.service.update(BigInt(id), {
name: dto.name,
type: dto.type as FulfillmentProviderType | undefined,
status: dto.status as FulfillmentProviderStatus | undefined,
configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
});
}
@Post(':id/recharge')
@HqOperation({
action: HqOperationAction.LOGISTICS_PROVIDER_RECHARGE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
includeBody: true,
})
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
}
}
@@ -0,0 +1,48 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
import { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
@Controller('admin/hq-accounts')
@UseGuards(HqAuthGuard)
export class AdminHqAccountsController {
constructor(private readonly service: AdminHqAccountsService) {}
@Get()
list(@Query() query: AdminHqAccountsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@UseGuards(SuperAdminGuard)
@HqOperation({
action: HqOperationAction.HQ_ACCOUNT_CREATE,
refType: 'HQ_ACCOUNT',
refIdField: 'id',
includeBody: true,
})
create(@Body() dto: CreateHqAccountDto) {
return this.service.create(dto);
}
@Put(':id')
@UseGuards(SuperAdminGuard)
@HqOperation({
action: HqOperationAction.HQ_ACCOUNT_UPDATE,
refType: 'HQ_ACCOUNT',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateHqAccountDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,189 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
import { hashPassword } from '../../common/crypto/password.util';
function mapHqAccountRow(account: {
id: bigint;
phone: string;
loginName: string | null;
passwordHash: string | null;
name: string;
adminRole: string;
status: string;
lastLoginAt: Date | null;
createdAt: Date;
}) {
return {
id: account.id,
phone: account.phone,
loginName: account.loginName,
hasPassword: !!account.passwordHash,
name: account.name,
adminRole: account.adminRole,
status: account.status,
lastLoginAt: account.lastLoginAt,
createdAt: account.createdAt,
};
}
@Injectable()
export class AdminHqAccountsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminHqAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.HqAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.adminRole) where.adminRole = query.adminRole as Prisma.EnumHqAdminRoleFilter['equals'];
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.hqAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
loginName: true,
passwordHash: true,
name: true,
adminRole: true,
status: true,
lastLoginAt: true,
createdAt: true,
},
}),
this.prisma.hqAccount.count({ where }),
]);
return serializeBigInt({
items: items.map(mapHqAccountRow),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const account = await this.prisma.hqAccount.findUnique({
where: { id },
select: {
id: true,
phone: true,
loginName: true,
passwordHash: true,
name: true,
adminRole: true,
status: true,
lastLoginAt: true,
createdAt: true,
},
});
if (!account) throw new NotFoundException('HQ 账号不存在');
return serializeBigInt(mapHqAccountRow(account));
}
async create(dto: CreateHqAccountDto) {
const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
if (dto.credentialType === 'phone') {
if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号');
const phone = dto.phone.trim();
const exists = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (exists) throw new BadRequestException('手机号已存在');
const account = await this.prisma.hqAccount.create({
data: { phone, name: dto.name, adminRole },
});
return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null }));
}
if (!dto.loginName?.trim() || !dto.password) {
throw new BadRequestException('账号密码模式需填写用户名和密码');
}
const loginName = dto.loginName.trim();
const loginTaken = await this.prisma.hqAccount.findUnique({ where: { loginName } });
if (loginTaken) throw new BadRequestException('用户名已存在');
const phone = dto.phone?.trim() || (await this.generatePlaceholderPhone());
const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('手机号已存在');
const account = await this.prisma.hqAccount.create({
data: {
phone,
loginName,
passwordHash: hashPassword(dto.password),
name: dto.name,
adminRole,
},
});
return serializeBigInt(mapHqAccountRow(account));
}
async update(id: bigint, dto: UpdateHqAccountDto) {
const current = await this.prisma.hqAccount.findUnique({ where: { id } });
if (!current) throw new NotFoundException('HQ 账号不存在');
if (dto.loginName !== undefined) {
const loginName = dto.loginName.trim();
if (!loginName) throw new BadRequestException('用户名不能为空');
const conflict = await this.prisma.hqAccount.findFirst({
where: { loginName, id: { not: id } },
});
if (conflict) throw new BadRequestException('用户名已存在');
}
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号');
}
const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (phoneTaken && phoneTaken.id !== id) {
throw new BadRequestException('手机号已存在');
}
}
const account = await this.prisma.hqAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}),
...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}),
...(dto.adminRole !== undefined
? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }
: {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
select: {
id: true,
phone: true,
loginName: true,
passwordHash: true,
name: true,
adminRole: true,
status: true,
lastLoginAt: true,
createdAt: true,
},
});
return serializeBigInt(mapHqAccountRow(account));
}
private async generatePlaceholderPhone(): Promise<string> {
for (let i = 0; i < 8; i += 1) {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`.slice(-8);
const phone = `199${suffix}`;
const exists = await this.prisma.hqAccount.findUnique({ where: { phone } });
if (!exists) return phone;
}
throw new BadRequestException('无法生成占位手机号,请手动填写');
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminHqLogsService } from './admin-hq-logs.service';
import { AdminHqLogsQueryDto } from './dto/admin-query.dto';
@Controller('admin/logs/hq')
@UseGuards(HqAuthGuard)
export class AdminHqLogsController {
constructor(private readonly service: AdminHqLogsService) {}
@Get()
list(@Query() query: AdminHqLogsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,96 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { hqOperationLogWhere } from '../../common/event/event.helpers';
import { resolveHqOperationLabel } from '../../common/hq-operation/hq-operation.constants';
import type { AdminHqLogsQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminHqLogsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminHqLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = hqOperationLogWhere({
hqAccountId: query.hqAccountId ? BigInt(query.hqAccountId) : undefined,
action: query.action,
refType: query.refType,
from: query.from ? new Date(query.from) : undefined,
to: query.to ? new Date(query.to) : undefined,
});
const [rows, total] = await Promise.all([
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonEvent.count({ where }),
]);
const hqIds = [...new Set(rows.map((r) => r.actorId).filter((id): id is bigint => id != null))];
const hqAccounts = hqIds.length
? await this.prisma.hqAccount.findMany({
where: { id: { in: hqIds } },
select: { id: true, name: true, phone: true, adminRole: true },
})
: [];
const hqMap = new Map(hqAccounts.map((a) => [a.id.toString(), a]));
return serializeBigInt({
items: rows.map((row) => {
const hq = row.actorId ? hqMap.get(row.actorId.toString()) : undefined;
const action = row.param1Desc === 'action' ? row.param1 : row.param1;
return {
id: row.id,
hqAccountId: row.actorId,
hqName: hq?.name ?? null,
hqPhone: hq?.phone ?? null,
hqRole: hq?.adminRole ?? null,
action,
actionLabel: resolveHqOperationLabel(action, row.refType),
refType: row.param2Desc === 'target_type' ? row.param2 : row.refType,
refId: row.param3Desc === 'target_id' ? row.param3 : row.refId.toString(),
status: row.status,
remark: row.remark,
detail: row.extraJson,
createdAt: row.createdAt,
};
}),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonEvent.findFirst({
where: { id, eventType: 'HQ_OPERATION' },
});
if (!row) throw new NotFoundException('操作日志不存在');
const hq = row.actorId
? await this.prisma.hqAccount.findUnique({
where: { id: row.actorId },
select: { id: true, name: true, phone: true, adminRole: true },
})
: null;
const action = row.param1Desc === 'action' ? row.param1 : row.param1;
return serializeBigInt({
id: row.id,
hqAccountId: row.actorId,
hqAccount: hq,
action,
actionLabel: resolveHqOperationLabel(action, row.refType),
refType: row.param2Desc === 'target_type' ? row.param2 : row.refType,
refId: row.param3Desc === 'target_id' ? row.param3 : row.refId.toString(),
status: row.status,
remark: row.remark,
detail: row.extraJson,
createdAt: row.createdAt,
});
}
}
@@ -0,0 +1,50 @@
import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
import { SaveHqAccountPermissionsDto, SaveHqRolePermissionsDto } from './dto/admin-mutate.dto';
@Controller('admin/hq-permissions')
@UseGuards(HqAuthGuard, SuperAdminGuard)
export class AdminHqPermissionsController {
constructor(private readonly service: AdminHqPermissionsService) {}
@Get('catalog')
catalog() {
return this.service.catalog();
}
@Get('roles/:role')
getRolePermissions(@Param('role') role: string) {
return this.service.getRolePermissions(role);
}
@Put('roles/:role')
@HqOperation({
action: HqOperationAction.HQ_PERMISSION_UPDATE,
refType: 'HQ_ROLE',
refIdParam: 'role',
includeBody: true,
})
saveRolePermissions(@Param('role') role: string, @Body() dto: SaveHqRolePermissionsDto) {
return this.service.saveRolePermissions(role, dto.permissionKeys);
}
@Get('accounts/:id')
getAccountPermissions(@Param('id') id: string) {
return this.service.getAccountPermissions(BigInt(id));
}
@Put('accounts/:id')
@HqOperation({
action: HqOperationAction.HQ_PERMISSION_UPDATE,
refType: 'HQ_ACCOUNT',
refIdParam: 'id',
includeBody: true,
})
saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) {
return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys);
}
}
@@ -0,0 +1,133 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
const VALID_PERMISSION_KEYS = new Set<string>([
...HQ_PERMISSION_CATALOG.map((p) => p.key),
LEGACY_SYSTEM_SETTINGS_KEY,
]);
function assertPermissionKeys(keys: string[]) {
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
if (invalid.length) {
throw new BadRequestException(`无效权限项: ${invalid.join(', ')}`);
}
}
@Injectable()
export class AdminHqPermissionsService {
constructor(private readonly prisma: PrismaService) {}
catalog() {
return {
permissions: HQ_PERMISSION_CATALOG,
roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({
role,
permissionKeys,
})),
};
}
async getRolePermissions(role: string) {
const rows = await this.prisma.hqRolePermission.findMany({
where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' },
select: { permissionKey: true },
});
const permissionKeys =
rows.length > 0
? expandHqPermissionKeys(rows.map((r) => r.permissionKey))
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
return { role, permissionKeys };
}
async saveRolePermissions(role: string, permissionKeys: string[]) {
if (role === 'SUPER_ADMIN') {
throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
}
assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys);
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
await this.prisma.$transaction([
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
...(normalized.length
? [
this.prisma.hqRolePermission.createMany({
data: normalized.map((permissionKey) => ({ adminRole, permissionKey })),
}),
]
: []),
]);
return this.getRolePermissions(role);
}
async getAccountPermissions(accountId: bigint) {
const account = await this.prisma.hqAccount.findUnique({
where: { id: accountId },
select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true },
});
if (!account) throw new NotFoundException('HQ 账号不存在');
const userPerms = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: accountId },
select: { permissionKey: true },
});
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = [
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
] as HqPermissionKey[];
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
return serializeBigInt({
account,
permissionKeys: userPermissionKeys,
rolePermissionKeys,
userPermissionKeys,
effectivePermissionKeys,
});
}
const rolePerms = await this.getRolePermissions(account.adminRole);
const effectivePermissionKeys = [
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
return serializeBigInt({
account,
permissionKeys: userPermissionKeys,
rolePermissionKeys: rolePerms.permissionKeys,
userPermissionKeys,
effectivePermissionKeys,
});
}
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('HQ 账号不存在');
assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys);
await this.prisma.$transaction([
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
...(normalized.length
? [
this.prisma.hqAccountPermission.createMany({
data: normalized.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
}),
]
: []),
]);
return this.getAccountPermissions(accountId);
}
}
@@ -0,0 +1,77 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { TradeService } from '../trade/trade.service';
import {
AdminCreateInvoiceDto,
IssueInvoiceDto,
RejectInvoiceDto,
} from '../trade/dto/after-sale.dto';
@Controller('admin/invoices')
@UseGuards(HqAuthGuard)
export class AdminInvoicesController {
constructor(private readonly tradeService: TradeService) {}
@Get()
list(
@Query('status') status?: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.tradeService.adminListInvoices({
status,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post()
@HqOperation({
action: HqOperationAction.INVOICE_CREATE,
refType: 'INVOICE',
batch: true,
includeBody: true,
})
create(@Body() body: AdminCreateInvoiceDto) {
return this.tradeService.adminCreateInvoice(body);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.tradeService.adminGetInvoice(BigInt(id));
}
@Post(':id/issue')
@HqOperation({
action: HqOperationAction.INVOICE_ISSUE,
refType: 'INVOICE',
refIdParam: 'id',
includeBody: true,
})
issue(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: IssueInvoiceDto,
) {
return this.tradeService.adminIssueInvoice(BigInt(id), user.actorId, body);
}
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.INVOICE_REJECT,
refType: 'INVOICE',
refIdParam: 'id',
includeBody: true,
})
reject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: RejectInvoiceDto,
) {
return this.tradeService.adminRejectInvoice(BigInt(id), user.actorId, body.remark);
}
}
@@ -0,0 +1,136 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import type {
CreateKnowledgeBaseRequest,
CreateKnowledgeDocumentRequest,
UpdateKnowledgeBaseRequest,
} from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
@Controller('admin/knowledge-bases')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('knowledge_bases')
export class AdminKnowledgeBasesController {
constructor(private readonly service: AdminKnowledgeBasesService) {}
@Get()
async list(
@CurrentUser() user: AuthUser,
@Query('name') name?: string,
@Query('enabled') enabled?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.list(actor, {
name,
enabled,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
@Get('options')
async options(@CurrentUser() user: AuthUser) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.options(actor);
}
@Get(':id')
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.detail(actor, BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.KNOWLEDGE_BASE_CREATE,
refType: 'KNOWLEDGE_BASE',
includeBody: true,
})
async create(@CurrentUser() user: AuthUser, @Body() body: CreateKnowledgeBaseRequest) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.create(actor, body);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_BASE_UPDATE,
refType: 'KNOWLEDGE_BASE',
refIdField: 'id',
includeBody: true,
})
async update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: UpdateKnowledgeBaseRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.update(actor, BigInt(id), body);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_BASE_DELETE,
refType: 'KNOWLEDGE_BASE',
refIdField: 'id',
})
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.remove(actor, BigInt(id));
}
@Get(':id/documents')
async listDocuments(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.listDocuments(actor, BigInt(id));
}
@Post(':id/documents')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_DOC_CREATE,
refType: 'KNOWLEDGE_DOCUMENT',
includeBody: true,
})
async addDocument(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: CreateKnowledgeDocumentRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.addDocument(actor, BigInt(id), body);
}
@Delete(':id/documents/:docId')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_DOC_DELETE,
refType: 'KNOWLEDGE_DOCUMENT',
refIdField: 'docId',
})
async removeDocument(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('docId') docId: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.removeDocument(actor, BigInt(id), BigInt(docId));
}
}
@@ -0,0 +1,341 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateKnowledgeBaseRequest,
CreateKnowledgeDocumentRequest,
KnowledgeBaseDto,
KnowledgeBaseOptionDto,
KnowledgeDocumentDto,
UpdateKnowledgeBaseRequest,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
const TEXT_EXT = /\.(txt|md|markdown|csv|json|log)$/i;
@Injectable()
export class AdminKnowledgeBasesService {
constructor(private readonly prisma: PrismaService) {}
async resolveActor(actorId: bigint): Promise<ActorCtx> {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('账号不可用');
}
return {
actorId,
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
};
}
async list(
actor: ActorCtx,
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
name?: { contains: string };
enabled?: boolean;
createdByHqAccountId?: bigint;
} = {};
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
if (query.name?.trim()) where.name = { contains: query.name.trim() };
if (query.enabled === 'true' || query.enabled === 'false') {
where.enabled = query.enabled === 'true';
}
const [items, total] = await Promise.all([
this.prisma.knowledgeBase.findMany({
where,
orderBy: [{ id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: { _count: { select: { documents: true } } },
}),
this.prisma.knowledgeBase.count({ where }),
]);
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
const owners = ownerIds.length
? await this.prisma.hqAccount.findMany({
where: { id: { in: ownerIds } },
select: { id: true, name: true },
})
: [];
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
return serializeBigInt({
items: items.map((row) =>
this.toKbDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null, row._count.documents),
),
total,
page,
pageSize,
});
}
async options(actor: ActorCtx): Promise<KnowledgeBaseOptionDto[]> {
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
const rows = await this.prisma.knowledgeBase.findMany({
where,
orderBy: [{ id: 'desc' }],
include: { _count: { select: { documents: true } } },
});
return rows.map((r) => ({
id: r.id.toString(),
name: r.name,
enabled: r.enabled,
documentCount: r._count.documents,
}));
}
async detail(actor: ActorCtx, id: bigint) {
const row = await this.requireKb(actor, id);
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
const owner = await this.prisma.hqAccount.findUnique({
where: { id: row.createdByHqAccountId },
select: { name: true },
});
return this.toKbDto(row, actor, owner?.name ?? null, count);
}
async create(actor: ActorCtx, dto: CreateKnowledgeBaseRequest) {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('请填写名称');
const row = await this.prisma.knowledgeBase.create({
data: {
name,
description: dto.description?.trim() || null,
enabled: dto.enabled !== false,
createdByHqAccountId: actor.actorId,
},
});
return this.toKbDto(row, actor, null, 0);
}
async update(actor: ActorCtx, id: bigint, dto: UpdateKnowledgeBaseRequest) {
const row = await this.requireKb(actor, id);
this.requireWrite(actor, row);
if (!actor.isSuperAdmin) {
// 创建人可改名称/描述/启用
const data: {
name?: string;
description?: string | null;
enabled?: boolean;
} = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('名称不能为空');
data.name = name;
}
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
if (dto.enabled !== undefined) data.enabled = dto.enabled;
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
return this.toKbDto(updated, actor, null, count);
}
const data: {
name?: string;
description?: string | null;
enabled?: boolean;
} = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('名称不能为空');
data.name = name;
}
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
if (dto.enabled !== undefined) data.enabled = dto.enabled;
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
return this.toKbDto(updated, actor, null, count);
}
async remove(actor: ActorCtx, id: bigint) {
const row = await this.requireKb(actor, id);
this.requireWrite(actor, row);
await this.prisma.knowledgeBase.delete({ where: { id } });
return { ok: true };
}
async listDocuments(actor: ActorCtx, kbId: bigint) {
await this.requireKb(actor, kbId);
const rows = await this.prisma.knowledgeDocument.findMany({
where: { knowledgeBaseId: kbId },
orderBy: [{ id: 'desc' }],
});
return serializeBigInt({ items: rows.map((r) => this.toDocDto(r)) });
}
async addDocument(actor: ActorCtx, kbId: bigint, dto: CreateKnowledgeDocumentRequest) {
const kb = await this.requireKb(actor, kbId);
this.requireWrite(actor, kb);
const title = dto.title?.trim();
if (!title) throw new BadRequestException('请填写标题');
let contentText = dto.contentText?.trim() || '';
let status: 'READY' | 'EMPTY' | 'FAILED' = 'EMPTY';
let errorMessage: string | null = null;
if (contentText) {
status = 'READY';
} else if (dto.fileUrl?.trim()) {
const fileName = dto.fileName?.trim() || '';
if (TEXT_EXT.test(fileName) || isLikelyTextMime(dto.mimeType)) {
try {
contentText = await fetchText(dto.fileUrl.trim());
status = contentText.trim() ? 'READY' : 'EMPTY';
if (!contentText.trim()) errorMessage = '文件内容为空';
} catch (e) {
status = 'FAILED';
errorMessage = e instanceof Error ? e.message : String(e);
}
} else {
status = 'EMPTY';
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
}
} else {
throw new BadRequestException('请粘贴正文或上传文件');
}
const row = await this.prisma.knowledgeDocument.create({
data: {
knowledgeBaseId: kbId,
title,
fileName: dto.fileName?.trim() || null,
fileUrl: dto.fileUrl?.trim() || null,
mimeType: dto.mimeType?.trim() || null,
sizeBytes: dto.sizeBytes ?? null,
contentText: contentText || null,
status,
errorMessage,
},
});
return this.toDocDto(row);
}
async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) {
const kb = await this.requireKb(actor, kbId);
this.requireWrite(actor, kb);
const doc = await this.prisma.knowledgeDocument.findFirst({
where: { id: docId, knowledgeBaseId: kbId },
});
if (!doc) throw new NotFoundException('文档不存在');
await this.prisma.knowledgeDocument.delete({ where: { id: docId } });
return { ok: true };
}
private async requireKb(actor: ActorCtx, id: bigint) {
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
if (!row) throw new NotFoundException('知识库不存在');
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
throw new ForbiddenException('无权查看该知识库');
}
return row;
}
private requireWrite(
actor: ActorCtx,
row: { createdByHqAccountId: bigint },
) {
if (actor.isSuperAdmin) return;
if (row.createdByHqAccountId !== actor.actorId) {
throw new ForbiddenException('只能操作自己创建的知识库');
}
}
private toKbDto(
row: {
id: bigint;
name: string;
description: string | null;
enabled: boolean;
createdByHqAccountId: bigint;
createdAt: Date;
updatedAt: Date;
},
actor: ActorCtx,
createdByName: string | null,
documentCount: number,
): KnowledgeBaseDto {
const isOwner = row.createdByHqAccountId === actor.actorId;
return {
id: row.id.toString(),
name: row.name,
description: row.description,
enabled: row.enabled,
documentCount,
createdByHqAccountId: row.createdByHqAccountId.toString(),
createdByName,
isOwner,
canEditFull: actor.isSuperAdmin || isOwner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toDocDto(row: {
id: bigint;
knowledgeBaseId: bigint;
title: string;
fileName: string | null;
fileUrl: string | null;
mimeType: string | null;
sizeBytes: number | null;
contentText: string | null;
status: string;
errorMessage: string | null;
createdAt: Date;
updatedAt: Date;
}): KnowledgeDocumentDto {
const status =
row.status === 'READY' || row.status === 'FAILED' || row.status === 'EMPTY'
? row.status
: 'EMPTY';
return {
id: row.id.toString(),
knowledgeBaseId: row.knowledgeBaseId.toString(),
title: row.title,
fileName: row.fileName,
fileUrl: row.fileUrl,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
hasContent: !!(row.contentText && row.contentText.trim()),
status,
errorMessage: row.errorMessage,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function isLikelyTextMime(mime?: string | null) {
if (!mime) return false;
return (
mime.startsWith('text/') ||
mime === 'application/json' ||
mime === 'application/markdown'
);
}
async function fetchText(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`下载文件失败 HTTP ${res.status}`);
const buf = await res.arrayBuffer();
if (buf.byteLength > 2 * 1024 * 1024) throw new Error('文本文件超过 2MB');
return new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
@@ -0,0 +1,110 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import type {
CreateLlmApiConfigRequest,
UpdateLlmApiConfigRequest,
} from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminLlmConfigsService } from './admin-llm-configs.service';
@Controller('admin/llm-configs')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('llm_configs')
export class AdminLlmConfigsController {
constructor(private readonly service: AdminLlmConfigsService) {}
@Get()
async list(
@CurrentUser() user: AuthUser,
@Query('name') name?: string,
@Query('enabled') enabled?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.list(actor, {
name,
enabled,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
@Get('options')
async options(@CurrentUser() user: AuthUser) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.options(actor);
}
@Get(':id')
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.detail(actor, BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.LLM_CONFIG_CREATE,
refType: 'LLM_CONFIG',
includeBody: true,
})
async create(@CurrentUser() user: AuthUser, @Body() body: CreateLlmApiConfigRequest) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.create(actor, body);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.LLM_CONFIG_UPDATE,
refType: 'LLM_CONFIG',
refIdField: 'id',
includeBody: true,
})
async update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: UpdateLlmApiConfigRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.update(actor, BigInt(id), body);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.LLM_CONFIG_DELETE,
refType: 'LLM_CONFIG',
refIdField: 'id',
})
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.remove(actor, BigInt(id));
}
@Post(':id/test')
@HqOperation({
action: HqOperationAction.LLM_CONFIG_TEST,
refType: 'LLM_CONFIG',
refIdField: 'id',
})
async test(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.test(actor, BigInt(id));
}
}
@@ -0,0 +1,290 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
LLM_PROVIDERS,
LLM_PROVIDER_PRESETS,
type CreateLlmApiConfigRequest,
type LlmApiConfigDto,
type LlmApiConfigOptionDto,
type LlmProvider,
type UpdateLlmApiConfigRequest,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { LlmChatClient, normalizeLlmBaseUrl } from '../../integrations/llm/llm-chat.client';
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
function isProvider(v: string): v is LlmProvider {
return (LLM_PROVIDERS as readonly string[]).includes(v);
}
@Injectable()
export class AdminLlmConfigsService {
constructor(
private readonly prisma: PrismaService,
private readonly llm: LlmChatClient,
) {}
async resolveActor(actorId: bigint): Promise<ActorCtx> {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('账号不可用');
}
return {
actorId,
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
};
}
async list(
actor: ActorCtx,
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
name?: { contains: string };
enabled?: boolean;
createdByHqAccountId?: bigint;
} = {};
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
if (query.name?.trim()) where.name = { contains: query.name.trim() };
if (query.enabled === 'true' || query.enabled === 'false') {
where.enabled = query.enabled === 'true';
}
const [items, total] = await Promise.all([
this.prisma.llmApiConfig.findMany({
where,
orderBy: [{ id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.llmApiConfig.count({ where }),
]);
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
const owners = ownerIds.length
? await this.prisma.hqAccount.findMany({
where: { id: { in: ownerIds } },
select: { id: true, name: true },
})
: [];
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
return serializeBigInt({
items: items.map((row) => this.toDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null)),
total,
page,
pageSize,
});
}
/** 企微绑定下拉:已启用;非超管仅自己的 */
async options(actor: ActorCtx): Promise<LlmApiConfigOptionDto[]> {
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
const rows = await this.prisma.llmApiConfig.findMany({
where,
orderBy: [{ id: 'desc' }],
select: { id: true, name: true, provider: true, modelName: true, enabled: true },
});
return rows.map((r) => ({
id: r.id.toString(),
name: r.name,
provider: (isProvider(r.provider) ? r.provider : 'CUSTOM') as LlmProvider,
modelName: r.modelName,
enabled: r.enabled,
}));
}
async detail(actor: ActorCtx, id: bigint) {
const row = await this.requireReadable(actor, id);
const owner = await this.prisma.hqAccount.findUnique({
where: { id: row.createdByHqAccountId },
select: { name: true },
});
return this.toDto(row, actor, owner?.name ?? null);
}
async create(actor: ActorCtx, dto: CreateLlmApiConfigRequest) {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('请填写名称');
if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商');
const apiKey = dto.apiKey?.trim();
if (!apiKey) throw new BadRequestException('请填写 API Key');
const preset = LLM_PROVIDER_PRESETS[dto.provider];
const baseUrl = normalizeLlmBaseUrl(dto.baseUrl?.trim() || preset.defaultBaseUrl);
const modelName = dto.modelName?.trim() || preset.defaultModel;
if (!baseUrl) throw new BadRequestException('请填写 Base URL');
if (!modelName) throw new BadRequestException('请填写模型名');
const row = await this.prisma.llmApiConfig.create({
data: {
name,
provider: dto.provider,
baseUrl,
apiKey,
modelName,
temperature: dto.temperature ?? null,
maxTokens: dto.maxTokens ?? null,
systemPrompt: dto.systemPrompt?.trim() || null,
enabled: dto.enabled !== false,
createdByHqAccountId: actor.actorId,
},
});
return this.toDto(row, actor, null);
}
async update(actor: ActorCtx, id: bigint, dto: UpdateLlmApiConfigRequest) {
const row = await this.requireReadable(actor, id);
const isOwner = row.createdByHqAccountId === actor.actorId;
if (!actor.isSuperAdmin) {
if (!isOwner) throw new ForbiddenException('只能操作自己创建的配置');
// 非超管仅可改 enabled
const keys = Object.keys(dto).filter((k) => (dto as Record<string, unknown>)[k] !== undefined);
if (keys.some((k) => k !== 'enabled')) {
throw new ForbiddenException('非超级管理员只能修改配置是否生效');
}
if (dto.enabled === undefined) throw new BadRequestException('请指定 enabled');
const updated = await this.prisma.llmApiConfig.update({
where: { id },
data: { enabled: dto.enabled },
});
return this.toDto(updated, actor, null);
}
// 超管全量
let provider = row.provider as LlmProvider;
if (dto.provider !== undefined) {
if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商');
provider = dto.provider;
}
const preset = LLM_PROVIDER_PRESETS[provider];
const data: {
name?: string;
provider?: string;
baseUrl?: string;
apiKey?: string;
modelName?: string;
temperature?: number | null;
maxTokens?: number | null;
systemPrompt?: string | null;
enabled?: boolean;
} = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('名称不能为空');
data.name = name;
}
if (dto.provider !== undefined) data.provider = provider;
if (dto.baseUrl !== undefined) {
data.baseUrl = normalizeLlmBaseUrl(dto.baseUrl.trim() || preset.defaultBaseUrl);
if (!data.baseUrl) throw new BadRequestException('Base URL 不能为空');
}
if (dto.apiKey !== undefined && dto.apiKey.trim()) data.apiKey = dto.apiKey.trim();
if (dto.modelName !== undefined) {
data.modelName = dto.modelName.trim() || preset.defaultModel;
if (!data.modelName) throw new BadRequestException('模型名不能为空');
}
if (dto.temperature !== undefined) data.temperature = dto.temperature;
if (dto.maxTokens !== undefined) data.maxTokens = dto.maxTokens;
if (dto.systemPrompt !== undefined) data.systemPrompt = dto.systemPrompt?.trim() || null;
if (dto.enabled !== undefined) data.enabled = dto.enabled;
const updated = await this.prisma.llmApiConfig.update({ where: { id }, data });
return this.toDto(updated, actor, null);
}
async remove(actor: ActorCtx, id: bigint) {
if (!actor.isSuperAdmin) {
throw new ForbiddenException('仅超级管理员可删除语言模型配置');
}
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
if (!row) throw new NotFoundException('配置不存在');
await this.prisma.llmApiConfig.delete({ where: { id } });
return { ok: true };
}
async test(actor: ActorCtx, id: bigint) {
const row = await this.requireReadable(actor, id);
if (!row.enabled) throw new BadRequestException('配置未启用');
const reply = await this.llm.chat({
baseUrl: row.baseUrl,
apiKey: row.apiKey,
model: row.modelName,
temperature: row.temperature != null ? Number(row.temperature) : 0.2,
maxTokens: row.maxTokens ?? 64,
messages: [
{ role: 'system', content: '用一句话回复:连接成功。' },
{ role: 'user', content: 'ping' },
],
});
return { ok: true, reply };
}
private async requireReadable(actor: ActorCtx, id: bigint) {
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
if (!row) throw new NotFoundException('配置不存在');
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
throw new ForbiddenException('无权查看该配置');
}
return row;
}
private toDto(
row: {
id: bigint;
name: string;
provider: string;
baseUrl: string;
apiKey: string;
modelName: string;
temperature: { toNumber?: () => number } | number | null;
maxTokens: number | null;
systemPrompt: string | null;
enabled: boolean;
createdByHqAccountId: bigint;
createdAt: Date;
updatedAt: Date;
},
actor: ActorCtx,
createdByName: string | null,
): LlmApiConfigDto {
const isOwner = row.createdByHqAccountId === actor.actorId;
const temp =
row.temperature == null
? null
: typeof row.temperature === 'number'
? row.temperature
: Number(row.temperature);
return {
id: row.id.toString(),
name: row.name,
provider: (isProvider(row.provider) ? row.provider : 'CUSTOM') as LlmProvider,
baseUrl: row.baseUrl,
modelName: row.modelName,
apiKeyConfigured: !!row.apiKey,
temperature: temp,
maxTokens: row.maxTokens,
systemPrompt: row.systemPrompt,
enabled: row.enabled,
createdByHqAccountId: row.createdByHqAccountId.toString(),
createdByName,
isOwner,
canEditFull: actor.isSuperAdmin,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,92 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminOrdersService } from './admin-orders.service';
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@UseGuards(HqAuthGuard)
export class AdminOrdersController {
constructor(private readonly ordersService: AdminOrdersService) {}
@Get()
list(@Query() query: AdminOrdersQueryDto) {
return this.ordersService.list(query);
}
@Post('batch-delete')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@HqOperation({
action: HqOperationAction.ORDER_BATCH_DELETE,
refType: 'ORDER',
batch: true,
includeBody: true,
})
batchDelete(@Body() dto: BatchDeleteOrdersDto) {
return this.ordersService.batchDeleteOrders(dto.ids.map((id) => BigInt(id)));
}
@Get('ship-defaults')
shipDefaults() {
return this.ordersService.getShipDefaults();
}
@Get(':id')
detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id));
}
@Delete(':id')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@HqOperation({
action: HqOperationAction.ORDER_DELETE,
refType: 'ORDER',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.ordersService.deleteOrder(BigInt(id));
}
/** HQ 发货:调用小飞侠创建运单并更新配送信息 */
@Post(':id/ship')
@HqOperation({
action: HqOperationAction.ORDER_SHIP,
refType: 'ORDER',
refIdParam: 'id',
includeBody: true,
})
ship(@Param('id') id: string, @Body() dto: AdminShipOrderDto) {
return this.ordersService.shipOrder(BigInt(id), dto);
}
@Post(':id/logistics-ship')
@HqOperation({
action: HqOperationAction.ORDER_SHIP,
refType: 'ORDER',
refIdParam: 'id',
includeBody: true,
})
shipLogistics(@Param('id') id: string, @Body() dto: HqLogisticsShipDto) {
return this.ordersService.shipLogistics(BigInt(id), dto);
}
/** preV1 调试:直接改订单状态,不走业务校验 */
@Put(':id/status')
@HqOperation({
action: HqOperationAction.ORDER_STATUS_DEBUG,
refType: 'ORDER',
refIdParam: 'id',
includeBody: true,
})
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
return this.ordersService.updateStatusDebug(BigInt(id), dto.status);
}
}
@@ -0,0 +1,404 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { TradeService } from '../trade/trade.service';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import { AdminRedeemService } from './admin-redeem.service';
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly tradeService: TradeService,
private readonly xiaofeixiaService: AdminXiaofeixiaService,
private readonly fulfillmentService: FulfillmentService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
private readonly adminRedeemService: AdminRedeemService,
) {}
async list(query: AdminOrdersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderWhereInput = {};
if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId);
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
where.fulfillmentHold = true;
}
if (query.createdFrom || query.createdTo) {
where.createdAt = {};
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
city: { select: { id: true, name: true, code: true } },
fulfillmentWarehouse: { select: { id: true, name: true } },
},
}),
this.prisma.order.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
userNo: true,
phone: true,
nickname: true,
deviceKey: true,
phoneVerifiedAt: true,
},
},
delivery: true,
benefitCoupon: {
select: {
id: true,
couponNo: true,
totalAmount: true,
usedAmount: true,
balance: true,
status: true,
},
},
city: { select: { id: true, name: true, code: true } },
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
imageResource: { select: { url: true } },
fulfillmentWarehouse: {
select: {
id: true,
name: true,
contactName: true,
contactPhone: true,
address: true,
lng: true,
lat: true,
fulfillmentMode: true,
},
},
},
});
if (!order) throw new NotFoundException('订单不存在');
const statusLogs = await this.prisma.commonEvent.findMany({
where: orderStatusLogWhere(id),
orderBy: { createdAt: 'asc' },
});
const coupon = order.benefitCoupon;
const redeemTrace = coupon
? await this.adminRedeemService.buildCouponRedeemTrace(coupon)
: { redeemSummary: null, redeemRecords: [] };
const { benefitCoupon: _coupon, ...orderRest } = order;
return serializeBigInt(
mapOrderCompat({
...orderRest,
statusLogs: mapStatusLogCompat(statusLogs),
benefitCoupons: coupon
? [
{
id: coupon.id,
couponNo: coupon.couponNo,
totalAmount: Number(coupon.totalAmount),
usedAmount: Number(coupon.usedAmount),
balance: Number(coupon.balance),
status: coupon.status,
},
]
: [],
redeemSummary: redeemTrace.redeemSummary,
redeemRecords: redeemTrace.redeemRecords,
}),
);
}
async updateStatusDebug(id: bigint, status: string) {
const order = await this.prisma.order.findUnique({ where: { id } });
if (!order) throw new NotFoundException('订单不存在');
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
return this.detail(id);
}
async shipOrder(id: bigint, dto: AdminShipOrderDto) {
if (dto.provider !== 'XFX') {
throw new BadRequestException('暂仅支持小飞侠配送');
}
let order = await this.prisma.order.findUnique({
where: { id },
include: {
delivery: true,
fulfillmentWarehouse: true,
},
});
if (!order) throw new NotFoundException('订单不存在');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
if (order.delivery?.trackingNo) {
throw new BadRequestException('该订单已有运单号,请勿重复发货');
}
if (dto.warehouseId) {
const warehouseId = BigInt(dto.warehouseId);
const warehouseRow = await this.prisma.cityWarehouse.findFirst({
where: { id: warehouseId, status: 'ACTIVE' },
});
if (!warehouseRow) {
throw new BadRequestException('仓库不存在或已停用');
}
if (order.fulfillmentWarehouseId !== warehouseId) {
await this.prisma.order.update({
where: { id },
data: { fulfillmentWarehouseId: warehouseId },
});
order = await this.prisma.order.findUniqueOrThrow({
where: { id },
include: { delivery: true, fulfillmentWarehouse: true },
});
}
}
const warehouse = order.fulfillmentWarehouse;
if (!warehouse) {
throw new BadRequestException('请先选择履约仓库');
}
const providerId =
order.delivery?.fulfillmentProviderId ??
warehouse?.fulfillmentProviderId ??
null;
let xfxConfig;
if (providerId) {
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId);
} else {
xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
if (!xfxConfig) {
throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商');
}
}
const defaults = this.getShipDefaults(warehouse);
const shipmentDto: XiaofeixiaCreateShipmentDto = {
outNumber: order.orderNo,
fromName: dto.fromName || defaults.fromName,
fromMobile: dto.fromMobile || defaults.fromMobile,
fromAddress: dto.fromAddress || defaults.fromAddress,
fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail,
fromLng: dto.fromLng ?? defaults.fromLng,
fromLat: dto.fromLat ?? defaults.fromLat,
toName: order.receiverName,
toMobile: order.receiverPhone,
toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
toAddressDetail: order.receiverAddress,
goodsName: order.productName,
goodsNum: order.quantity,
weight: dto.weight ?? defaults.weight,
payMode: dto.payMode || defaults.payMode,
remark: dto.remark || `HQ发货 ${order.orderNo}`,
};
const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig);
if (!result.ok || !result.data) {
throw new BadRequestException(result.error || '小飞侠创建运单失败');
}
const { providerShipmentId, trackingNumber } = result.data;
const now = new Date();
const resolvedProviderId = providerId;
await this.prisma.$transaction(async (tx) => {
if (order.delivery) {
await tx.orderDelivery.update({
where: { orderId: id },
data: {
provider: 'XFX',
fulfillmentProviderId: resolvedProviderId,
trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId),
shippingAt: now,
},
});
} else {
await tx.orderDelivery.create({
data: {
orderId: id,
provider: 'XFX',
fulfillmentProviderId: resolvedProviderId,
trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId),
shippingAt: now,
},
});
}
await tx.order.update({
where: { id },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
});
await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
return this.detail(id);
}
getShipDefaults(warehouse?: {
contactName: string;
contactPhone: string;
address: string;
name: string;
lng: { toNumber?: () => number } | number | null;
lat: { toNumber?: () => number } | number | null;
} | null) {
const lng =
warehouse?.lng != null
? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng
? Number(warehouse.lng)
: Number(warehouse.lng)
: 113.665;
const lat =
warehouse?.lat != null
? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat
? Number(warehouse.lat)
: Number(warehouse.lat)
: 34.757;
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: warehouse?.contactName || '杜康仓库',
fromMobile: warehouse?.contactPhone || '13800000000',
fromAddress: warehouse?.address || '河南省郑州市金水区',
fromAddressDetail: warehouse?.name || '杜康酒业仓',
fromLng: lng,
fromLat: lat,
weight: 2,
payMode: '1',
};
}
/** 总部传统快递填单(同城无仓 / 跨城) */
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
await this.fulfillmentService.shipHqLogistics(id, dto);
return this.detail(id);
}
async batchDeleteOrders(ids: bigint[]) {
const uniqueIds = [...new Set(ids)];
if (!uniqueIds.length) {
return { ok: true, deleted: 0, message: '未选择订单' };
}
const orders = await this.prisma.order.findMany({
where: { id: { in: uniqueIds } },
select: { id: true, orderNo: true },
});
if (!orders.length) throw new NotFoundException('订单不存在');
const orderIds = orders.map((o) => o.id);
await this.prisma.$transaction(async (tx) => {
await this.deleteOrdersInTx(tx, orderIds);
});
return {
ok: true,
deleted: orderIds.length,
orderNos: orders.map((o) => o.orderNo),
message: '订单及关联业务数据已删除,状态流转等业务日志已保留',
};
}
async deleteOrder(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
select: { id: true, orderNo: true },
});
if (!order) throw new NotFoundException('订单不存在');
await this.prisma.$transaction(async (tx) => {
await this.deleteOrdersInTx(tx, [id]);
});
return {
ok: true,
deleted: 1,
orderNo: order.orderNo,
message: `订单 ${order.orderNo} 及关联业务数据已删除`,
};
}
private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) {
if (!orderIds.length) return;
const couponIds = (
await tx.benefitCoupon.findMany({
where: { orderId: { in: orderIds } },
select: { id: true },
})
).map((c) => c.id);
if (couponIds.length) {
const redeemIds = (
await tx.redeemRecord.findMany({
where: {
OR: [
{ couponId: { in: couponIds } },
{ allocations: { some: { couponId: { in: couponIds } } } },
],
},
select: { id: true },
})
).map((r) => r.id);
if (redeemIds.length) {
await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
}
await tx.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
await tx.wineryBillItem.deleteMany({ where: { orderId: { in: orderIds } } });
await tx.logisticsBillItem.deleteMany({ where: { orderId: { in: orderIds } } });
await tx.order.updateMany({
where: { originOrderId: { in: orderIds } },
data: { originOrderId: null },
});
await tx.commonTicket.deleteMany({
where: { refType: 'ORDER', refId: { in: orderIds } },
});
await tx.order.deleteMany({ where: { id: { in: orderIds } } });
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminOssLogsService } from './admin-oss-logs.service';
import { AdminOssLogsQueryDto } from './dto/admin-query.dto';
@Controller('admin/logs/oss')
@UseGuards(HqAuthGuard)
export class AdminOssLogsController {
constructor(private readonly service: AdminOssLogsService) {}
@Get()
list(@Query() query: AdminOssLogsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,99 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminOssLogsQueryDto } from './dto/admin-query.dto';
function mapOssLogRow(row: {
id: bigint;
scene: string;
refType: string | null;
refId: bigint | null;
requestBody: unknown;
responseBody: unknown;
externalNo: string | null;
status: string;
errorMessage: string | null;
createdAt: Date;
}) {
const req = (row.requestBody ?? {}) as Record<string, unknown>;
const res = (row.responseBody ?? {}) as Record<string, unknown>;
return {
id: row.id,
scene: row.scene,
status: row.status,
actorType: row.refType,
actorId: row.refId,
clientApp: (req.clientApp as string | undefined) ?? null,
bizType: (req.bizType as string | undefined) ?? null,
mediaType: (req.mediaType as string | undefined) ?? null,
fileName: (req.fileName as string | undefined) ?? null,
fileSize: (req.fileSize as number | undefined) ?? null,
mimeType: (req.mimeType as string | undefined) ?? null,
ossKey: (res.ossKey as string | undefined) ?? row.externalNo ?? null,
url: (res.url as string | undefined) ?? null,
bucket: (res.bucket as string | undefined) ?? null,
mock: (res.mock as boolean | undefined) ?? null,
errorMessage: row.errorMessage,
createdAt: row.createdAt,
requestBody: row.requestBody,
responseBody: row.responseBody,
};
}
@Injectable()
export class AdminOssLogsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminOssLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.LogThirdPartyWhereInput = {
provider: 'ALIYUN_OSS',
};
if (query.scene) where.scene = query.scene;
if (query.status) where.status = query.status as Prisma.EnumThirdPartyLogStatusFilter['equals'];
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
const andFilters: Prisma.LogThirdPartyWhereInput[] = [];
if (query.bizType) {
andFilters.push({
requestBody: { string_contains: `"bizType":"${query.bizType}"` },
});
}
if (query.clientApp) {
andFilters.push({
requestBody: { string_contains: `"clientApp":"${query.clientApp}"` },
});
}
if (andFilters.length) {
where.AND = andFilters;
}
const [rows, total] = await Promise.all([
this.prisma.logThirdParty.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logThirdParty.count({ where }),
]);
return serializeBigInt({
items: rows.map(mapOssLogRow),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.logThirdParty.findFirst({
where: { id, provider: 'ALIYUN_OSS' },
});
if (!row) throw new NotFoundException('OSS 上传日志不存在');
return serializeBigInt(mapOssLogRow(row));
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPartnerLogsService } from './admin-partner-logs.service';
import { AdminPartnerLogsQueryDto } from './dto/admin-query.dto';
@Controller('admin/logs/partners')
@UseGuards(HqAuthGuard)
export class AdminPartnerLogsController {
constructor(private readonly service: AdminPartnerLogsService) {}
@Get()
list(@Query() query: AdminPartnerLogsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(id);
}
}
@@ -0,0 +1,161 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
eventNamesForPartnerLogCategory,
resolvePartnerLogCategory,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminPartnerLogsQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminPartnerLogsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminPartnerLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const partnerAccountIds = await this.resolvePartnerAccountIds(query);
if (partnerAccountIds && partnerAccountIds.length === 0) {
return { items: [], total: 0, page, pageSize };
}
const where = this.buildWhere(query, partnerAccountIds);
const [rows, total] = await Promise.all([
this.prisma.logPartnerAnalytics.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logPartnerAnalytics.count({ where }),
]);
const items = await this.enrichRows(rows);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: string) {
const row = await this.prisma.logPartnerAnalytics.findUnique({ where: { id: BigInt(id) } });
if (!row) throw new NotFoundException('日志不存在');
const [item] = await this.enrichRows([row]);
return serializeBigInt(item);
}
private buildWhere(
query: AdminPartnerLogsQueryDto,
partnerAccountIds?: bigint[],
): Prisma.LogPartnerAnalyticsWhereInput {
const where: Prisma.LogPartnerAnalyticsWhereInput = {};
if (partnerAccountIds) where.partnerAccountId = { in: partnerAccountIds };
if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
const categoryEvents = query.eventName
? [query.eventName]
: query.category
? eventNamesForPartnerLogCategory(query.category)
: undefined;
if (categoryEvents?.length) where.eventName = { in: categoryEvents };
if (query.from || query.to) {
where.createdAt = {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
};
}
return where;
}
private async expandPrimaryWithChildren(primaryIds: bigint[]): Promise<bigint[]> {
if (!primaryIds.length) return [];
const children = await this.prisma.partnerAccount.findMany({
where: { parentAccountId: { in: primaryIds } },
select: { id: true },
});
return [...primaryIds, ...children.map((c) => c.id)];
}
private async resolvePartnerAccountIds(
query: AdminPartnerLogsQueryDto,
): Promise<bigint[] | undefined> {
if (query.partnerAccountId) return [BigInt(query.partnerAccountId)];
if (query.partnerId) {
return this.expandPrimaryWithChildren([BigInt(query.partnerId)]);
}
if (query.phone) {
const accounts = await this.prisma.partnerAccount.findMany({
where: { phone: { contains: query.phone } },
select: { id: true },
take: 200,
});
return accounts.map((a) => a.id);
}
if (query.companyName) {
const primaries = await this.prisma.partnerAccount.findMany({
where: { isPrimary: 1, companyName: { contains: query.companyName } },
select: { id: true },
take: 100,
});
return this.expandPrimaryWithChildren(primaries.map((a) => a.id));
}
return undefined;
}
private async enrichRows(
rows: Array<{
id: bigint;
partnerAccountId: bigint;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: bigint | null;
extraJson: unknown;
createdAt: Date;
}>,
) {
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId))];
const accounts = accountIds.length
? await this.prisma.partnerAccount.findMany({
where: { id: { in: accountIds } },
select: {
id: true,
name: true,
phone: true,
companyName: true,
isPrimary: true,
parentAccountId: true,
staffRole: true,
parent: { select: { id: true, companyName: true } },
},
})
: [];
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
return rows.map((row) => {
const account = accountMap.get(row.partnerAccountId.toString());
const isSubAccount = !!account?.parentAccountId;
const primaryId = isSubAccount
? account?.parent?.id?.toString() ?? null
: account?.id.toString() ?? null;
return {
id: row.id.toString(),
partnerId: primaryId ?? row.partnerAccountId.toString(),
partnerAccountId: row.partnerAccountId.toString(),
accountName: account?.name ?? null,
accountPhone: account?.phone ?? null,
companyName: isSubAccount ? null : account?.companyName ?? null,
isSubAccount,
staffRole: account?.staffRole ?? null,
category: resolvePartnerLogCategory(row.eventName),
eventName: row.eventName,
clientApp: row.clientApp,
refType: row.refType,
refId: row.refId?.toString() ?? null,
extraJson: (row.extraJson as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt,
};
});
}
}
@@ -0,0 +1,103 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminPartnersService } from './admin-partners.service';
import { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import {
CreatePartnerAccountDto,
CreatePartnerDto,
UpdatePartnerAccountDto,
UpdatePartnerDto,
} from './dto/admin-mutate.dto';
@Controller('admin/partners')
@UseGuards(HqAuthGuard)
export class AdminPartnersController {
constructor(private readonly service: AdminPartnersService) {}
@Get()
list(@Query() query: AdminPartnersQueryDto) {
return this.service.listPartners(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailPartner(BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.PARTNER_CREATE,
refType: 'PARTNER',
refIdField: 'id',
includeBody: true,
})
create(@Body() dto: CreatePartnerDto) {
return this.service.createPartner(dto);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.PARTNER_UPDATE,
refType: 'PARTNER',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdatePartnerDto) {
return this.service.updatePartner(BigInt(id), dto);
}
}
@Controller('admin/partner-accounts')
@UseGuards(HqAuthGuard)
export class AdminPartnerAccountsController {
constructor(private readonly service: AdminPartnersService) {}
@Get('tree')
tree(@Query('partnerId') partnerId?: string) {
return this.service.listPartnerAccountTree(partnerId ? BigInt(partnerId) : undefined);
}
@Get()
list(@Query() query: AdminPartnerAccountsQueryDto) {
return this.service.listPartnerAccounts(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailPartnerAccount(BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.PARTNER_ACCOUNT_CREATE,
refType: 'PARTNER_ACCOUNT',
refIdField: 'id',
includeBody: true,
})
create(@Body() dto: CreatePartnerAccountDto) {
return this.service.createPartnerAccount(dto);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.PARTNER_ACCOUNT_UPDATE,
refType: 'PARTNER_ACCOUNT',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdatePartnerAccountDto) {
return this.service.updatePartnerAccount(BigInt(id), dto);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.PARTNER_ACCOUNT_DELETE,
refType: 'PARTNER_ACCOUNT',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.service.deletePartnerSubAccount(BigInt(id));
}
}
@@ -0,0 +1,473 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import type {
CreatePartnerAccountDto,
CreatePartnerDto,
UpdatePartnerAccountDto,
UpdatePartnerDto,
} from './dto/admin-mutate.dto';
const PRIMARY_WHERE = { isPrimary: 1 } as const;
function assertPartnerCommissionRates(
city: { maxPartnerCommissionRate: Prisma.Decimal | number | null },
orderCommissionRate: number,
redeemCommissionRate: number,
) {
const maxRate = resolveMaxPartnerCommissionRate(
city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null,
);
const check = validatePartnerCommissionRates(orderCommissionRate, redeemCommissionRate, maxRate);
if (!check.ok) throw new BadRequestException(check.message);
}
@Injectable()
export class AdminPartnersService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listPartners(query: AdminPartnersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = { ...PRIMARY_WHERE };
if (query.companyName) where.companyName = { contains: query.companyName };
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone };
if (query.phone) where.phone = { contains: query.phone };
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
managedWarehouse: { select: { id: true, name: true } },
children: {
select: {
id: true,
phone: true,
name: true,
staffRole: true,
permissions: true,
status: true,
},
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, children: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
id: p.id.toString(),
companyName: p.companyName,
contactPhone: p.contactPhone,
phone: p.phone,
name: p.name,
cityId: p.cityId?.toString() ?? null,
cityName: p.city?.name ?? null,
maxPartnerCommissionRate:
p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null,
scopeType: p.scopeType,
districtCodes: this.partnerCityService.parseDistrictCodes(p.districtCodes),
orderCommissionRate: Number(p.orderCommissionRate ?? 0),
redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03),
bindingStatus: p.bindingStatus,
managedWarehouseId: p.managedWarehouseId?.toString() ?? null,
managedWarehouseName: p.managedWarehouse?.name ?? null,
storeCount: p._count.stores,
accountCount: p._count.children + 1,
children: p.children.map((c) => ({
id: c.id.toString(),
phone: c.phone,
name: c.name,
staffRole: c.staffRole,
permissions: c.permissions,
status: c.status,
})),
createdAt: p.createdAt,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const account = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
include: {
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
managedWarehouse: { select: { id: true, name: true } },
children: {
where: { isPrimary: 0 },
select: {
id: true,
phone: true,
name: true,
staffRole: true,
permissions: true,
status: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, children: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt({
...this.partnerCityService.toDto({
...account,
city: account.city,
}),
contactPhone: account.contactPhone,
address: account.address,
bankAccountName: account.bankAccountName,
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
managedWarehouseName: account.managedWarehouse?.name ?? null,
accountCount: account._count.children + 1,
maxPartnerCommissionRate:
account.city?.maxPartnerCommissionRate != null
? Number(account.city.maxPartnerCommissionRate)
: null,
children: account.children.map((c) => ({
id: c.id.toString(),
phone: c.phone,
name: c.name,
staffRole: c.staffRole,
permissions: c.permissions,
status: c.status,
createdAt: c.createdAt,
})),
});
}
async createPartner(dto: CreatePartnerDto) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
const cityId = BigInt(dto.cityId);
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
await this.partnerCityService.validatePrimaryBinding(cityId, {
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes: dto.districtCodes,
});
const orderCommissionRate = dto.orderCommissionRate ?? 0;
const redeemCommissionRate = dto.redeemCommissionRate ?? 0.03;
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name: dto.name.trim(),
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId,
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes:
dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull,
orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName?.trim() || null,
address: dto.address?.trim() || null,
contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName,
bankAccountNo: dto.bankAccountNo,
bankBranch: dto.bankBranch,
weeklyStoreTarget: dto.weeklyStoreTarget ?? 20,
},
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const existing = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
});
if (!existing) throw new NotFoundException('开城合伙人不存在');
const city = await this.prisma.commonCity.findUniqueOrThrow({ where: { id: existing.cityId! } });
const cityId = existing.cityId!;
const scopeType = (dto.scopeType ?? existing.scopeType) as CityPartnerScopeType;
const districtCodes =
scopeType === 'DISTRICT'
? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes)
: null;
await this.partnerCityService.validatePrimaryBinding(
cityId,
{
partnerAccountId: id.toString(),
scopeType,
districtCodes: districtCodes ?? undefined,
},
id,
);
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken && phoneTaken.id !== id) {
throw new BadRequestException('该手机号已被使用');
}
}
const orderCommissionRate =
dto.orderCommissionRate !== undefined
? dto.orderCommissionRate
: Number(existing.orderCommissionRate ?? 0);
const redeemCommissionRate =
dto.redeemCommissionRate !== undefined
? dto.redeemCommissionRate
: Number(existing.redeemCommissionRate ?? 0.03);
if (dto.orderCommissionRate !== undefined || dto.redeemCommissionRate !== undefined) {
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
}
const phoneChanged =
dto.phone !== undefined && dto.phone.trim() !== existing.phone;
const account = await this.prisma.partnerAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
...(phoneChanged ? { wxOpenId: null, wxUnionId: null } : {}),
...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}),
...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}),
...(dto.scopeType !== undefined || dto.districtCodes !== undefined
? {
districtCodes:
scopeType === 'DISTRICT'
? ((districtCodes ?? []) as Prisma.InputJsonValue)
: Prisma.JsonNull,
}
: {}),
...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}),
...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}),
...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}),
...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}),
...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}),
...(dto.bankAccountNo !== undefined ? { bankAccountNo: dto.bankAccountNo } : {}),
...(dto.bankBranch !== undefined ? { bankBranch: dto.bankBranch } : {}),
...(dto.weeklyStoreTarget !== undefined ? { weeklyStoreTarget: dto.weeklyStoreTarget } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = { isPrimary: 0 };
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.parentAccountId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async listPartnerAccountTree(primaryAccountId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = primaryAccountId
? { OR: [{ id: primaryAccountId }, { parentAccountId: primaryAccountId }] }
: { isPrimary: 1 };
const accounts = await this.prisma.partnerAccount.findMany({
where,
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
});
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
const nodeMap = new Map<string, TreeNode>();
const roots: TreeNode[] = [];
for (const account of accounts) {
nodeMap.set(account.id.toString(), { ...account, children: [] });
}
for (const account of accounts) {
const node = nodeMap.get(account.id.toString())!;
if (account.parentAccountId) {
const parent = nodeMap.get(account.parentAccountId.toString());
if (parent) parent.children.push(node);
else roots.push(node);
} else if (account.isPrimary === 1) {
roots.push(node);
}
}
const mapNode = (node: TreeNode) => ({
id: node.id,
phone: node.phone,
name: node.name,
status: node.status,
isPrimary: node.isPrimary,
staffRole: node.staffRole,
permissions: node.permissions,
parentAccountId: node.parentAccountId,
companyName: node.companyName,
createdAt: node.createdAt,
lastLoginAt: node.lastLoginAt,
children: node.children.length ? node.children.map(mapNode) : undefined,
});
return serializeBigInt(roots.map(mapNode));
}
async detailPartnerAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({
where: { id },
include: {
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
});
if (!account) throw new NotFoundException('合伙人账号不存在');
const primary = await this.partnerCityService.resolvePrimaryAccount(id);
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const [bills, orders] = await Promise.all([
this.prisma.partnerBill.findMany({
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
take: 50,
}),
this.prisma.order.findMany({
where: orderWhere,
orderBy: { createdAt: 'desc' },
take: 50,
select: {
id: true,
orderNo: true,
status: true,
payAmount: true,
createdAt: true,
user: { select: { userNo: true, phone: true } },
},
}),
]);
return serializeBigInt({ ...account, primaryAccountId: primary.id, bills, orders });
}
async createPartnerAccount(dto: CreatePartnerAccountDto) {
if (!dto.parentAccountId) {
throw new BadRequestException('请指定主账号 parentAccountId 创建子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) },
});
if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅可向主账号添加子账号,不支持多级子账号');
}
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name: dto.name.trim(),
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
});
return serializeBigInt(account);
}
async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) {
const existing = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('合伙人账号不存在');
if (existing.isPrimary === 1) {
throw new BadRequestException('请通过开城合伙人接口编辑主账号');
}
const data: Prisma.PartnerAccountUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER';
if (dto.permissions !== undefined) data.permissions = dto.permissions;
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken && phoneTaken.id !== id) {
throw new BadRequestException('该手机号已被使用');
}
data.phone = phone;
if (phone !== existing.phone) {
data.wxOpenId = null;
data.wxUnionId = null;
}
}
const account = await this.prisma.partnerAccount.update({ where: { id }, data });
return serializeBigInt(account);
}
async deletePartnerSubAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (!account.parentAccountId) {
throw new BadRequestException('仅可删除子账号');
}
await this.prisma.partnerAccount.delete({ where: { id } });
return { ok: true };
}
}
@@ -0,0 +1,48 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
import { AdminProductDetailTemplatesQueryDto } from './dto/admin-query.dto';
import {
CreateProductDetailTemplateDto,
UpdateProductDetailTemplateDto,
} from './dto/admin-mutate.dto';
@Controller('admin/product-detail-templates')
@UseGuards(HqAuthGuard)
export class AdminProductDetailTemplatesController {
constructor(private readonly service: AdminProductDetailTemplatesService) {}
@Get()
list(@Query() query: AdminProductDetailTemplatesQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.PRODUCT_TEMPLATE_CREATE,
refType: 'PRODUCT_TEMPLATE',
refIdField: 'id',
includeBody: true,
})
create(@Body() dto: CreateProductDetailTemplateDto) {
return this.service.create(dto);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.PRODUCT_TEMPLATE_UPDATE,
refType: 'PRODUCT_TEMPLATE',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateProductDetailTemplateDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,183 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminProductDetailTemplatesQueryDto } from './dto/admin-query.dto';
import type {
CreateProductDetailTemplateDto,
UpdateProductDetailTemplateDto,
} from './dto/admin-mutate.dto';
type TemplateRow = {
id: bigint;
code: string;
name: string;
description: string | null;
aromaType: string | null;
storyTitle: string | null;
storyText: string | null;
features: unknown;
detailImageUrls?: unknown;
suggestedDetailImageCount: number;
sortOrder: number;
status: string;
createdAt: Date;
updatedAt: Date;
};
const MAX_TEMPLATE_DETAIL_IMAGES = 30;
@Injectable()
export class AdminProductDetailTemplatesService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminProductDetailTemplatesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonProductDetailTemplateWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) {
where.status = query.status as Prisma.EnumDetailTemplateStatusFilter['equals'];
}
if (query.aromaType) {
where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
}
const [items, total] = await Promise.all([
this.prisma.commonProductDetailTemplate.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonProductDetailTemplate.count({ where }),
]);
return serializeBigInt({
items: items.map((row) => this.format(row)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonProductDetailTemplate.findUnique({ where: { id } });
if (!row) throw new NotFoundException('详情模板不存在');
return serializeBigInt(this.format(row));
}
async create(dto: CreateProductDetailTemplateDto) {
const exists = await this.prisma.commonProductDetailTemplate.findUnique({
where: { code: dto.code },
});
if (exists) throw new BadRequestException('模板编码已存在');
const detailImageUrls = this.normalizeDetailImageUrls(dto.detailImageUrls);
const row = await this.prisma.commonProductDetailTemplate.create({
data: {
code: dto.code,
name: dto.name,
description: dto.description,
aromaType: dto.aromaType as Prisma.CommonProductDetailTemplateCreateInput['aromaType'],
storyTitle: dto.storyTitle,
storyText: dto.storyText,
features: this.normalizeFeatures(dto.features) as Prisma.InputJsonValue,
detailImageUrls: detailImageUrls as Prisma.InputJsonValue,
suggestedDetailImageCount:
detailImageUrls.length > 0 ? detailImageUrls.length : (dto.suggestedDetailImageCount ?? 1),
sortOrder: dto.sortOrder ?? 0,
status: (dto.status ?? 'ACTIVE') as Prisma.CommonProductDetailTemplateCreateInput['status'],
} as Prisma.CommonProductDetailTemplateCreateInput,
});
return serializeBigInt(this.format(row));
}
async update(id: bigint, dto: UpdateProductDetailTemplateDto) {
const existing = await this.prisma.commonProductDetailTemplate.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('详情模板不存在');
if (dto.code && dto.code !== existing.code) {
const dup = await this.prisma.commonProductDetailTemplate.findUnique({ where: { code: dto.code } });
if (dup) throw new BadRequestException('模板编码已存在');
}
const data: Prisma.CommonProductDetailTemplateUpdateInput = {};
if (dto.code !== undefined) data.code = dto.code;
if (dto.name !== undefined) data.name = dto.name;
if (dto.description !== undefined) data.description = dto.description;
if (dto.aromaType !== undefined) {
data.aromaType = dto.aromaType as Prisma.CommonProductDetailTemplateUpdateInput['aromaType'];
}
if (dto.storyTitle !== undefined) data.storyTitle = dto.storyTitle;
if (dto.storyText !== undefined) data.storyText = dto.storyText;
if (dto.features !== undefined) {
data.features = this.normalizeFeatures(dto.features) as Prisma.InputJsonValue;
}
if (dto.detailImageUrls !== undefined) {
const detailImageUrls = this.normalizeDetailImageUrls(dto.detailImageUrls);
(data as Prisma.CommonProductDetailTemplateUpdateInput & { detailImageUrls?: Prisma.InputJsonValue }).detailImageUrls =
detailImageUrls as Prisma.InputJsonValue;
data.suggestedDetailImageCount =
detailImageUrls.length > 0
? detailImageUrls.length
: (dto.suggestedDetailImageCount ?? existing.suggestedDetailImageCount);
} else if (dto.suggestedDetailImageCount !== undefined) {
data.suggestedDetailImageCount = dto.suggestedDetailImageCount;
}
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
if (dto.status !== undefined) {
data.status = dto.status as Prisma.CommonProductDetailTemplateUpdateInput['status'];
}
const row = await this.prisma.commonProductDetailTemplate.update({ where: { id }, data });
return serializeBigInt(this.format(row));
}
private normalizeDetailImageUrls(urls?: string[]) {
if (!urls) return [];
const cleaned = urls.map((u) => u?.trim()).filter(Boolean);
if (cleaned.length > MAX_TEMPLATE_DETAIL_IMAGES) {
throw new BadRequestException(`详情图最多 ${MAX_TEMPLATE_DETAIL_IMAGES}`);
}
return cleaned;
}
private normalizeFeatures(features?: Array<{ icon: string; title: string; desc: string }>) {
if (!features) return [];
return features
.filter((f) => f.title?.trim() || f.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() ?? '',
desc: f.desc?.trim() ?? '',
}));
}
private format(row: TemplateRow) {
const features = Array.isArray(row.features)
? (row.features as Array<{ icon: string; title: string; desc: string }>)
: [];
const detailImageUrls = Array.isArray(row.detailImageUrls)
? (row.detailImageUrls as string[]).filter(Boolean)
: [];
return {
id: row.id.toString(),
code: row.code,
name: row.name,
description: row.description,
aromaType: row.aromaType,
storyTitle: row.storyTitle,
storyText: row.storyText,
features,
detailImageUrls,
suggestedDetailImageCount: detailImageUrls.length || row.suggestedDetailImageCount,
sortOrder: row.sortOrder,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminProductsService } from './admin-products.service';
import { AdminProductsQueryDto } from './dto/admin-query.dto';
import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
@Controller('admin/products')
@UseGuards(HqAuthGuard)
export class AdminProductsController {
constructor(private readonly service: AdminProductsService) {}
@Get()
list(@Query() query: AdminProductsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@HqOperation({ action: HqOperationAction.PRODUCT_CREATE, refType: 'PRODUCT', refIdField: 'id', includeBody: true })
create(@Body() dto: CreateProductDto) {
return this.service.create(dto);
}
@Put(':id')
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -0,0 +1,392 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
function normalizePhones(phones?: string[]): string[] {
if (!phones?.length) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const raw of phones) {
const phone = String(raw || '')
.replace(/\D/g, '')
.trim();
if (!phone || seen.has(phone)) continue;
if (!/^1\d{10}$/.test(phone)) {
throw new BadRequestException(`手机号格式无效:${raw}`);
}
seen.add(phone);
out.push(phone);
}
return out;
}
const SKU_AUTO_PREFIX = 'DK';
const SKU_AUTO_PAD = 6;
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
function resolveFulfillmentFlags(input: {
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
allowOnSitePickup?: boolean;
defaults?: {
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
allowOnSitePickup: boolean;
};
}) {
const d = input.defaults ?? {
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
};
const allowOnlinePurchase = input.allowOnlinePurchase ?? d.allowOnlinePurchase;
const allowOnSitePickup = input.allowOnSitePickup ?? d.allowOnSitePickup;
let allowCrossCityDelivery = input.allowCrossCityDelivery ?? d.allowCrossCityDelivery;
if (!allowOnlinePurchase) {
allowCrossCityDelivery = false;
}
if (!allowOnlinePurchase && !allowOnSitePickup) {
throw new BadRequestException('请至少勾选「允许线上购买」或「允许现场取货」之一');
}
return { allowOnlinePurchase, allowCrossCityDelivery, allowOnSitePickup };
}
@Injectable()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminProductsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonProductItemWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonProductItem.findMany({
where,
orderBy: { sortOrder: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
},
}),
this.prisma.commonProductItem.count({ where }),
]);
const productIds = items.map((p) => p.id);
const resources = productIds.length
? await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: { in: productIds },
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
})
: [];
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt({
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
},
});
if (!product) throw new NotFoundException('商品不存在');
const resources = await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: id,
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
});
return serializeBigInt(this.formatProduct(product, resources));
}
async create(dto: CreateProductDto) {
const barcodeExists = await this.prisma.commonProductItem.findFirst({
where: { barcode69: dto.barcode69 },
});
if (barcodeExists) throw new BadRequestException('69 码已存在');
const flags = resolveFulfillmentFlags({
allowOnlinePurchase: dto.allowOnlinePurchase,
allowCrossCityDelivery: dto.allowCrossCityDelivery,
allowOnSitePickup: dto.allowOnSitePickup,
});
const phones = normalizePhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
const product = await this.createWithGeneratedSku({
barcode69: dto.barcode69,
name: dto.name,
subtitle: dto.subtitle,
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
spec: dto.spec,
price: dto.price,
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
allowOnSitePickup: flags.allowOnSitePickup,
allowOnlinePurchase: flags.allowOnlinePurchase,
allowCrossCityDelivery: flags.allowCrossCityDelivery,
visibilityWhitelistEnabled: whitelistEnabled,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
});
if (dto.coverUrl) {
await this.syncCover(product.id, dto.coverUrl);
}
await this.syncProductMedia(product.id, {
carouselUrls: dto.carouselUrls,
detailImageUrls: dto.detailImageUrls,
});
return this.detail(product.id);
}
async update(id: bigint, dto: UpdateProductDto) {
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('商品不存在');
const fulfillmentTouched =
dto.allowOnlinePurchase !== undefined ||
dto.allowCrossCityDelivery !== undefined ||
dto.allowOnSitePickup !== undefined;
const flags = fulfillmentTouched
? resolveFulfillmentFlags({
allowOnlinePurchase: dto.allowOnlinePurchase,
allowCrossCityDelivery: dto.allowCrossCityDelivery,
allowOnSitePickup: dto.allowOnSitePickup,
defaults: {
allowOnlinePurchase: existing.allowOnlinePurchase,
allowCrossCityDelivery: existing.allowCrossCityDelivery,
allowOnSitePickup: existing.allowOnSitePickup,
},
})
: null;
await this.prisma.commonProductItem.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.subtitle !== undefined ? { subtitle: dto.subtitle } : {}),
...(dto.spec !== undefined ? { spec: dto.spec } : {}),
...(dto.price !== undefined ? { price: dto.price } : {}),
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(flags
? {
allowOnSitePickup: flags.allowOnSitePickup,
allowOnlinePurchase: flags.allowOnlinePurchase,
allowCrossCityDelivery: flags.allowCrossCityDelivery,
}
: {}),
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
},
});
if (dto.visibilityPhones !== undefined) {
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
}
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
}
await this.syncProductMedia(id, {
carouselUrls: dto.carouselUrls,
detailImageUrls: dto.detailImageUrls,
});
return this.detail(id);
}
async remove(id: bigint) {
const product = await this.prisma.commonProductItem.findUnique({ where: { id } });
if (!product) throw new NotFoundException('商品不存在');
const orderCount = await this.prisma.order.count({ where: { productId: id } });
if (orderCount > 0) {
throw new BadRequestException(`该商品已有 ${orderCount} 笔关联订单,无法删除`);
}
await this.prisma.commonResource.deleteMany({
where: { ownerType: 'PRODUCT', ownerId: id },
});
await this.prisma.commonProductItem.delete({ where: { id } });
return { ok: true };
}
/** 生成 DK + 6 位自增 SKU,冲突重试 */
private async nextAutoSkuCode(): Promise<string> {
const rows = await this.prisma.commonProductItem.findMany({
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
select: { skuCode: true },
});
let maxSeq = 0;
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
for (const row of rows) {
const m = re.exec(row.skuCode);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
}
return `${SKU_AUTO_PREFIX}${String(maxSeq + 1).padStart(SKU_AUTO_PAD, '0')}`;
}
private async createWithGeneratedSku(
data: Omit<Prisma.CommonProductItemCreateInput, 'skuCode'>,
) {
for (let attempt = 0; attempt < 8; attempt++) {
const skuCode = await this.nextAutoSkuCode();
try {
return await this.prisma.commonProductItem.create({
data: { ...data, skuCode },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const target = err.meta?.target;
const fields = Array.isArray(target) ? target.map(String) : [String(target ?? '')];
if (fields.some((f) => f.includes('sku'))) {
continue;
}
}
throw err;
}
}
throw new BadRequestException('SKU 生成失败,请重试');
}
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
await this.prisma.$transaction(async (tx) => {
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
if (!phones.length) return;
await tx.commonProductVisibilityPhone.createMany({
data: phones.map((phone) => ({ productId, phone })),
});
});
}
private formatProduct(
product: Prisma.CommonProductItemGetPayload<{
include: {
coverResource: true;
visibilityPhones: { select: { phone: true } };
};
}>,
extraResources: Prisma.CommonResourceGetPayload<object>[],
) {
const media = mapProductMedia(product, extraResources);
const phones = product.visibilityPhones?.map((row) => row.phone) ?? [];
return {
...product,
visibilityPhones: phones,
price: Number(product.price),
benefitAmount: Number(product.benefitAmount ?? product.price),
...media,
};
}
private async syncCover(productId: bigint, coverUrl: string) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } });
if (product.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: product.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: productId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id: productId },
data: { coverResourceId: cover.id },
});
}
}
private async syncProductMedia(
productId: bigint,
dto: { carouselUrls?: string[]; detailImageUrls?: string[] },
) {
if (dto.carouselUrls !== undefined) {
await this.replaceProductResources(productId, 'CAROUSEL', dto.carouselUrls);
}
if (dto.detailImageUrls !== undefined) {
await this.replaceProductResources(productId, 'DETAIL', dto.detailImageUrls);
}
}
private async replaceProductResources(
productId: bigint,
bizType: 'CAROUSEL' | 'DETAIL',
urls: string[],
) {
const cleaned = urls.map((u) => u?.trim()).filter(Boolean);
await this.prisma.commonResource.deleteMany({
where: { ownerType: 'PRODUCT', ownerId: productId, bizType },
});
if (cleaned.length === 0) return;
await this.prisma.commonResource.createMany({
data: cleaned.map((url, sortOrder) => ({
ownerType: 'PRODUCT' as const,
ownerId: productId,
bizType,
mediaType: 'IMAGE' as const,
ossBucket: 'legacy',
ossKey: url,
url,
sortOrder,
status: 'ACTIVE' as const,
})),
});
}
}
@@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { TradeService } from '../trade/trade.service';
import {
HqProxyOrderCreateDto,
HqProxyOrderPayDto,
HqProxyOrderPreviewDto,
} from './dto/hq-proxy-order.dto';
@Controller('admin/proxy-orders')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('orders')
export class AdminProxyOrdersController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getHqProxyOrderOptions();
}
@Post('preview')
preview(@Body() dto: HqProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto, { bypassWhitelist: true });
}
@Post()
@HqOperation({
action: HqOperationAction.ORDER_PROXY_CREATE,
refType: 'ORDER',
batch: true,
includeBody: true,
})
create(
@CurrentUser() user: AuthUser,
@Body() dto: HqProxyOrderCreateDto,
@Req() req: Request,
) {
return this.tradeService.createHqProxyOrder(user.actorId, dto, req);
}
@Post(':id/pay')
pay(@Param('id') id: string, @Body() dto: HqProxyOrderPayDto) {
return this.tradeService.payHqProxyOrder(BigInt(id), dto.payMethod ?? 'NATIVE');
}
@Post(':id/pay/mock-confirm')
mockConfirmPay(@Param('id') id: string) {
return this.tradeService.mockConfirmProxyPay(BigInt(id), { hq: true });
}
@Get(':id/pay-status')
payStatus(@Param('id') id: string) {
return this.tradeService.getProxyPayStatus(BigInt(id));
}
}
@@ -0,0 +1,75 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugPhoneBalanceDto,
AdminRedeemDebugPhoneConfirmDto,
AdminRedeemDebugPhonePrepareDto,
AdminRedeemDebugPhoneStoreDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@Controller('admin/redeem/debug')
@UseGuards(HqAuthGuard)
export class AdminRedeemDebugController {
constructor(private readonly service: AdminRedeemDebugService) {}
/** preV1 调试:为用户生成核销码 */
@Post('create-token')
@HqOperation({
action: HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN,
refType: 'REDEEM_DEBUG',
batch: true,
includeBody: true,
})
createToken(@Body() dto: AdminRedeemDebugCreateTokenDto) {
return this.service.createToken(dto);
}
/** preV1 调试:门店侧预览核销 */
@Post('preview')
preview(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.preview(dto);
}
/** preV1 调试:门店侧确认核销 */
@Post('confirm')
@HqOperation({
action: HqOperationAction.REDEEM_DEBUG_CONFIRM,
refType: 'REDEEM_DEBUG',
batch: true,
includeBody: true,
})
confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.confirm(dto);
}
@Post('phone/send-lookup-sms')
sendPhoneLookupSms(@Body() dto: AdminRedeemDebugPhoneStoreDto) {
return this.service.sendPhoneLookupSms(dto);
}
@Post('phone/balance')
phoneBalance(@Body() dto: AdminRedeemDebugPhoneBalanceDto) {
return this.service.phoneBalance(dto);
}
@Post('phone/prepare')
phonePrepare(@Body() dto: AdminRedeemDebugPhonePrepareDto) {
return this.service.phonePrepare(dto);
}
@Post('phone/confirm')
@HqOperation({
action: HqOperationAction.REDEEM_DEBUG_CONFIRM,
refType: 'REDEEM_DEBUG',
batch: true,
includeBody: true,
})
phoneConfirm(@Body() dto: AdminRedeemDebugPhoneConfirmDto) {
return this.service.phoneConfirm(dto);
}
}
@@ -0,0 +1,111 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedeemService } from '../redeem/redeem.service';
import type {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugPhoneBalanceDto,
AdminRedeemDebugPhoneConfirmDto,
AdminRedeemDebugPhonePrepareDto,
AdminRedeemDebugPhoneStoreDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@Injectable()
export class AdminRedeemDebugService {
constructor(
private readonly prisma: PrismaService,
private readonly redeemService: RedeemService,
) {}
private parseStoreId(value: string): bigint {
const normalized = String(value ?? '').trim();
if (!normalized || !/^\d+$/.test(normalized)) {
throw new BadRequestException('门店 ID 格式无效');
}
return BigInt(normalized);
}
private async resolveUserId(identifier: string): Promise<bigint> {
const normalized = String(identifier ?? '').trim();
if (!normalized) {
throw new BadRequestException('请填写用户 ID、用户编号或手机号');
}
if (/^\d+$/.test(normalized)) {
const byId = await this.prisma.user.findUnique({
where: { id: BigInt(normalized) },
select: { id: true },
});
if (byId) return byId.id;
const byPhone = await this.prisma.user.findFirst({
where: { phone: normalized },
select: { id: true },
});
if (byPhone) return byPhone.id;
} else {
const byNo = await this.prisma.user.findFirst({
where: { userNo: normalized },
select: { id: true },
});
if (byNo) return byNo.id;
}
throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号');
}
private async resolveStoreAccountId(storeId: string): Promise<{ accountId: bigint; storeId: bigint }> {
const sid = this.parseStoreId(storeId);
const binding = await this.prisma.storeAccountStore.findFirst({
where: {
storeId: sid,
storeAccount: { status: 'ACTIVE', isPrimary: 1 },
},
orderBy: { id: 'asc' },
select: { storeAccountId: true, storeId: true },
});
if (!binding) {
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
}
return { accountId: binding.storeAccountId, storeId: binding.storeId };
}
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
const userId = await this.resolveUserId(dto.userId);
return this.redeemService.createToken(userId, {
amount: dto.amount,
couponId: dto.couponId?.trim() || undefined,
storeId: dto.storeId?.trim() || undefined,
});
}
async preview(dto: AdminRedeemDebugStoreTokenDto) {
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.previewRedeem(accountId, storeId, dto.token);
}
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(accountId, storeId, { token: dto.token });
}
async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) {
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.sendPhoneLookupSms(accountId, storeId, dto.phone);
}
async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) {
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.verifyPhoneAndGetBalance(accountId, storeId, dto.phone, dto.code);
}
async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) {
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.preparePhoneRedeem(accountId, storeId, dto.sessionId, dto.amount);
}
async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) {
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmPhoneRedeem(accountId, storeId, dto.sessionId, dto.code);
}
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { RedeemService } from '../redeem/redeem.service';
import {
AdminRedeemPendingQueryDto,
AdminRedeemPendingRejectDto,
} from './dto/admin-redeem-pending.dto';
@Controller('admin/redeem-pending')
@UseGuards(HqAuthGuard)
export class AdminRedeemPendingController {
constructor(private readonly redeemService: RedeemService) {}
@Get()
list(@Query() query: AdminRedeemPendingQueryDto) {
return this.redeemService.listPendingRedeems(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.redeemService.getPendingRedeem(BigInt(id));
}
@Post(':id/complete')
@HqOperation({
action: HqOperationAction.REDEEM_PENDING_COMPLETE,
refType: 'REDEEM_PENDING',
refIdParam: 'id',
})
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.redeemService.completePendingRedeem(BigInt(id), user.actorId);
}
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.REDEEM_PENDING_REJECT,
refType: 'REDEEM_PENDING',
refIdParam: 'id',
includeBody: true,
})
reject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: AdminRedeemPendingRejectDto,
) {
return this.redeemService.rejectPendingRedeem(BigInt(id), user.actorId, body.reason);
}
}
@@ -0,0 +1,50 @@
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
import { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
import { UpdateDeliveryDto } from './dto/admin-mutate.dto';
@Controller('admin/redeem-records')
@UseGuards(HqAuthGuard)
export class AdminRedeemRecordsController {
constructor(private readonly service: AdminRedeemService) {}
@Get()
list(@Query() query: AdminRedeemRecordsQueryDto) {
return this.service.listRecords(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailRecord(BigInt(id));
}
}
@Controller('admin/deliveries')
@UseGuards(HqAuthGuard)
export class AdminDeliveriesController {
constructor(private readonly service: AdminDeliveriesService) {}
@Get()
list(@Query() query: AdminDeliveriesQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Put(':id')
@HqOperation({
action: HqOperationAction.DELIVERY_UPDATE,
refType: 'DELIVERY',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateDeliveryDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,389 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { DeliveryProvider } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
export type CouponRedeemTrace = {
redeemSummary: {
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
redeemCount: number;
redeemRecordSum: number;
};
redeemRecords: Array<{
id: bigint;
redeemNo: string;
amount: number;
settleAmount: number;
couponAmount: number;
role: 'PRIMARY' | 'SECONDARY';
createdAt: Date;
store: { id: bigint; name: string; cityName: string | null } | null;
}>;
};
@Injectable()
export class AdminRedeemService {
constructor(private readonly prisma: PrismaService) {}
async listRecords(query: AdminRedeemRecordsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.RedeemRecordWhereInput = {};
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.userId) where.userId = BigInt(query.userId);
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
where.channel = query.channel;
}
const [items, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: { select: { id: true, name: true, cityName: true } },
coupon: { select: { id: true, couponNo: true, balance: true } },
},
}),
this.prisma.redeemRecord.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detailRecord(id: bigint) {
const record = await this.prisma.redeemRecord.findUnique({
where: { id },
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: {
select: {
id: true,
name: true,
cityName: true,
address: true,
partnerAccount: { select: { id: true, companyName: true } },
},
},
coupon: {
select: {
id: true,
couponNo: true,
totalAmount: true,
usedAmount: true,
balance: true,
status: true,
orderId: true,
order: { select: { id: true, orderNo: true } },
},
},
allocations: {
orderBy: { sortOrder: 'asc' },
include: {
coupon: {
select: {
id: true,
couponNo: true,
order: { select: { id: true, orderNo: true } },
},
},
},
},
payout: true,
rating: true,
},
});
if (!record) throw new NotFoundException('核销记录不存在');
return serializeBigInt({
...record,
allocations: record.allocations.map((a) => ({
couponId: a.couponId,
amount: Number(a.amount),
sortOrder: a.sortOrder,
couponNo: a.coupon.couponNo,
orderId: a.coupon.order?.id ?? null,
orderNo: a.coupon.order?.orderNo ?? null,
})),
});
}
/** 按权益券聚合核销追溯(含跨券 FIFO 次券) */
async buildCouponRedeemTrace(coupon: {
id: bigint;
couponNo: string;
totalAmount: Prisma.Decimal | number;
usedAmount: Prisma.Decimal | number;
balance: Prisma.Decimal | number;
status: string;
}): Promise<CouponRedeemTrace> {
await this.ensureRedeemAllocationsForCoupon(coupon.id);
const redeemRows = await this.prisma.redeemRecord.findMany({
where: {
OR: [
{ couponId: coupon.id },
{ allocations: { some: { couponId: coupon.id } } },
],
},
orderBy: { createdAt: 'desc' },
include: {
store: { select: { id: true, name: true, cityName: true } },
allocations: {
where: { couponId: coupon.id },
select: { amount: true, sortOrder: true },
},
},
});
const redeemRecords = redeemRows.map((r) => {
const couponAmount =
r.allocations[0] != null
? Number(r.allocations[0].amount)
: r.couponId === coupon.id
? Number(r.amount)
: 0;
return {
id: r.id,
redeemNo: r.redeemNo,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
couponAmount,
role: (r.couponId === coupon.id ? 'PRIMARY' : 'SECONDARY') as 'PRIMARY' | 'SECONDARY',
createdAt: r.createdAt,
store: r.store,
};
});
const redeemRecordSum = redeemRecords.reduce((sum, r) => sum + r.couponAmount, 0);
return {
redeemSummary: {
couponNo: coupon.couponNo,
totalAmount: Number(coupon.totalAmount),
usedAmount: Number(coupon.usedAmount),
balance: Number(coupon.balance),
status: coupon.status,
redeemCount: redeemRecords.length,
redeemRecordSum,
},
redeemRecords,
};
}
private async ensureRedeemAllocationsForCoupon(couponId: bigint) {
const coupon = await this.prisma.benefitCoupon.findUnique({
where: { id: couponId },
select: { id: true, userId: true },
});
if (!coupon) return;
const pendings = await this.prisma.redeemPendingRecord.findMany({
where: { userId: coupon.userId, redeemRecordId: { not: null } },
select: { redeemRecordId: true, allocationsJson: true },
});
for (const pending of pendings) {
if (!pending.redeemRecordId) continue;
const allocs = this.parseAllocationsJson(pending.allocationsJson);
if (!allocs.some((a) => a.couponId === couponId.toString())) continue;
const existing = await this.prisma.redeemRecordAllocation.count({
where: { redeemRecordId: pending.redeemRecordId },
});
if (existing > 0) continue;
await this.prisma.redeemRecordAllocation.createMany({
data: allocs.map((a, index) => ({
redeemRecordId: pending.redeemRecordId!,
couponId: BigInt(a.couponId),
amount: a.amount,
sortOrder: index,
})),
skipDuplicates: true,
});
}
const ledgers = await this.prisma.commonEvent.findMany({
where: {
eventType: 'BENEFIT_LEDGER',
param1: 'REDEEM',
param2: couponId.toString(),
actorType: 'USER',
actorId: coupon.userId,
refType: 'STORE',
},
select: { id: true, refId: true, amount1: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
for (const ledger of ledgers) {
if (ledger.refId == null || ledger.amount1 == null) continue;
const allocAmount = Math.abs(Number(ledger.amount1));
if (!(allocAmount > 0)) continue;
const already = await this.prisma.redeemRecordAllocation.findFirst({
where: {
couponId,
amount: allocAmount,
redeemRecord: {
userId: coupon.userId,
storeId: ledger.refId,
createdAt: {
gte: new Date(ledger.createdAt.getTime() - 8000),
lte: new Date(ledger.createdAt.getTime() + 8000),
},
},
},
select: { id: true },
});
if (already) continue;
const candidates = await this.prisma.redeemRecord.findMany({
where: {
userId: coupon.userId,
storeId: ledger.refId,
createdAt: {
gte: new Date(ledger.createdAt.getTime() - 8000),
lte: new Date(ledger.createdAt.getTime() + 8000),
},
amount: { gte: allocAmount },
allocations: { none: { couponId } },
},
orderBy: { createdAt: 'asc' },
take: 5,
});
if (!candidates.length) continue;
const target =
candidates.find((r) => r.couponId !== couponId) ??
(candidates.length === 1 ? candidates[0] : null);
if (!target) continue;
await this.prisma.redeemRecordAllocation
.create({
data: {
redeemRecordId: target.id,
couponId,
amount: allocAmount,
sortOrder: target.couponId === couponId ? 0 : 1,
},
})
.catch(() => {
/* unique 冲突忽略 */
});
}
const primaryWithoutAlloc = await this.prisma.redeemRecord.findMany({
where: {
couponId,
allocations: { none: {} },
},
select: { id: true, amount: true },
});
if (primaryWithoutAlloc.length) {
await this.prisma.redeemRecordAllocation.createMany({
data: primaryWithoutAlloc.map((r) => ({
redeemRecordId: r.id,
couponId,
amount: r.amount,
sortOrder: 0,
})),
skipDuplicates: true,
});
}
}
private parseAllocationsJson(
value: Prisma.JsonValue,
): Array<{ couponId: string; amount: number }> {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const row = item as { couponId?: unknown; amount?: unknown };
const id = row.couponId != null ? String(row.couponId) : '';
const amount = Number(row.amount);
if (!id || !Number.isFinite(amount) || amount <= 0) return null;
return { couponId: id, amount };
})
.filter((item): item is { couponId: string; amount: number } => !!item);
}
}
@Injectable()
export class AdminDeliveriesService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminDeliveriesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderDeliveryWhereInput = {};
if (query.provider) where.provider = query.provider as DeliveryProvider;
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
if (query.orderNo) {
where.order = { orderNo: { contains: query.orderNo } };
}
const [items, total] = await Promise.all([
this.prisma.orderDelivery.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
order: {
select: {
id: true,
orderNo: true,
status: true,
receiverName: true,
receiverPhone: true,
deliveryType: true,
productName: true,
quantity: true,
},
},
},
}),
this.prisma.orderDelivery.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const delivery = await this.prisma.orderDelivery.findUnique({
where: { id },
include: {
order: {
include: {
user: { select: { id: true, userNo: true, phone: true } },
imageResource: { select: { url: true } },
},
},
},
});
if (!delivery) throw new NotFoundException('配送单不存在');
return serializeBigInt(delivery);
}
async update(id: bigint, dto: UpdateDeliveryDto) {
const delivery = await this.prisma.orderDelivery.update({
where: { id },
data: {
...(dto.provider !== undefined ? { provider: dto.provider as DeliveryProvider } : {}),
...(dto.providerOrderNo !== undefined ? { providerOrderNo: dto.providerOrderNo } : {}),
...(dto.trackingNo !== undefined ? { trackingNo: dto.trackingNo } : {}),
},
include: { order: { select: { orderNo: true, status: true } } },
});
return serializeBigInt(delivery);
}
}

Some files were not shown because too many files have changed in this diff Show More