feat: multi-module iteration
This commit is contained in:
@@ -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(常带 .html,Taro 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('未配置腾讯位置服务 Key(TENCENT_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('未配置腾讯位置服务 Key(TENCENT_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('未配置腾讯位置服务 Key(TENCENT_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 已为 Global,Health 直接注入探活 */
|
||||
@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 返回非 JSON(HTTP ${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
Reference in New Issue
Block a user