webadmin端

批量删除用户
模板上传图片数量限制改成30
小飞侠接口配置
This commit is contained in:
2026-07-06 18:04:02 +08:00
parent 4e18763968
commit f0b99ea9da
17 changed files with 1172 additions and 49 deletions
+7
View File
@@ -67,3 +67,10 @@ XIAOFEIXIA_MCH_ID=
XIAOFEIXIA_API_KEY=
XIAOFEIXIA_SIGN_TYPE=MD5
# XIAOFEIXIA_APP_ID=
# HQ 订单发货默认寄件信息
SHIP_FROM_NAME=杜康仓库
SHIP_FROM_MOBILE=13800000000
SHIP_FROM_ADDRESS=河南省郑州市金水区
SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓
SHIP_FROM_LNG=113.665
SHIP_FROM_LAT=34.757
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
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 { AdminOrdersService } from './admin-orders.service';
import { UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@@ -14,11 +15,28 @@ export class AdminOrdersController {
return this.ordersService.list(query);
}
@Post('batch-delete')
@UseGuards(SuperAdminGuard)
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));
}
/** HQ 发货:调用小飞侠创建运单并更新配送信息 */
@Post(':id/ship')
ship(@Param('id') id: string, @Body() dto: AdminShipOrderDto) {
return this.ordersService.shipOrder(BigInt(id), dto);
}
/** preV1 调试:直接改订单状态,不走业务校验 */
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
@@ -1,17 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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 } from './dto/admin-mutate.dto';
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly tradeService: TradeService,
private readonly xiaofeixiaService: AdminXiaofeixiaService,
private readonly config: ConfigService,
) {}
async list(query: AdminOrdersQueryDto) {
@@ -87,4 +93,156 @@ export class AdminOrdersService {
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
return this.detail(id);
}
getShipDefaults() {
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: this.config.get<string>('SHIP_FROM_NAME') || '杜康仓库',
fromMobile: this.config.get<string>('SHIP_FROM_MOBILE') || '13800000000',
fromAddress: this.config.get<string>('SHIP_FROM_ADDRESS') || '河南省郑州市金水区',
fromAddressDetail: this.config.get<string>('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓',
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
weight: 2,
payMode: '1',
};
}
async shipOrder(id: bigint, dto: AdminShipOrderDto) {
if (dto.provider !== 'XFX') {
throw new BadRequestException('暂仅支持小飞侠配送');
}
const order = await this.prisma.order.findUnique({
where: { id },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
if (order.delivery?.trackingNo) {
throw new BadRequestException('该订单已有运单号,请勿重复发货');
}
const defaults = this.getShipDefaults();
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);
if (!result.ok || !result.data) {
throw new BadRequestException(result.error || '小飞侠创建运单失败');
}
const { providerShipmentId, trackingNumber } = result.data;
const now = new Date();
await this.prisma.$transaction(async (tx) => {
if (order.delivery) {
await tx.orderDelivery.update({
where: { orderId: id },
data: {
provider: 'XFX',
trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId),
shippingAt: now,
},
});
} else {
await tx.orderDelivery.create({
data: {
orderId: id,
provider: 'XFX',
trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId),
shippingAt: now,
},
});
}
});
await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
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: '订单及关联业务数据已删除,状态流转等业务日志已保留',
};
}
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: { 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.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
}
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 } } });
}
}
@@ -25,7 +25,7 @@ type TemplateRow = {
updatedAt: Date;
};
const MAX_TEMPLATE_DETAIL_IMAGES = 20;
const MAX_TEMPLATE_DETAIL_IMAGES = 30;
@Injectable()
export class AdminProductDetailTemplatesService {
@@ -0,0 +1,31 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import type {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@Controller('admin/redeem/debug')
@UseGuards(HqAuthGuard)
export class AdminRedeemDebugController {
constructor(private readonly service: AdminRedeemDebugService) {}
/** preV1 调试:为用户生成核销码 */
@Post('create-token')
createToken(@Body() dto: AdminRedeemDebugCreateTokenDto) {
return this.service.createToken(dto);
}
/** preV1 调试:门店侧预览核销 */
@Post('preview')
preview(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.preview(dto);
}
/** preV1 调试:门店侧确认核销 */
@Post('confirm')
confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.confirm(dto);
}
}
@@ -0,0 +1,45 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedeemService } from '../redeem/redeem.service';
import type {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@Injectable()
export class AdminRedeemDebugService {
constructor(
private readonly prisma: PrismaService,
private readonly redeemService: RedeemService,
) {}
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
const account = await this.prisma.storeAccount.findFirst({
where: { storeId: BigInt(storeId), status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { id: true, store: { select: { name: true } } },
});
if (!account) {
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
}
return account.id;
}
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
return this.redeemService.createToken(BigInt(dto.userId), {
amount: dto.amount,
couponId: dto.couponId,
storeId: dto.storeId,
});
}
async preview(dto: AdminRedeemDebugStoreTokenDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.previewRedeem(storeAccountId, dto.token);
}
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
}
}
@@ -1,8 +1,9 @@
import { Controller, Delete, Get, Param, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminUsersService } from './admin-users.service';
import { AdminUsersQueryDto } from './dto/admin-query.dto';
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto';
@Controller('admin/users')
@UseGuards(HqAuthGuard)
@@ -14,6 +15,21 @@ export class AdminUsersController {
return this.usersService.list(query);
}
@Post('batch-delete/preview')
@UseGuards(SuperAdminGuard)
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
}
@Post('batch-delete')
@UseGuards(SuperAdminGuard)
batchDelete(@Body() dto: BatchDeleteUsersConfirmDto) {
return this.usersService.batchDeleteUsers(
dto.ids.map((id) => BigInt(id)),
dto.confirmRisk,
);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.usersService.detail(BigInt(id));
@@ -1,9 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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 { AdminUsersQueryDto } from './dto/admin-query.dto';
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
function mapAdminUserRow(u: {
id: bigint;
userNo: string;
@@ -115,46 +117,108 @@ export class AdminUsersService {
});
}
async previewBatchDelete(ids: bigint[]) {
const uniqueIds = [...new Set(ids)];
if (!uniqueIds.length) {
return { items: [], hasRisk: false, total: 0 };
}
const users = await this.prisma.user.findMany({
where: { id: { in: uniqueIds } },
select: {
id: true,
userNo: true,
nickname: true,
phone: true,
orders: {
where: { status: { notIn: [...FINISHED_ORDER_STATUSES] } },
select: { id: true, orderNo: true, status: true, payAmount: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
redeemRecords: {
select: {
id: true,
redeemNo: true,
amount: true,
createdAt: true,
payout: { select: { status: true } },
},
orderBy: { createdAt: 'desc' },
},
},
orderBy: { createdAt: 'desc' },
});
const foundIds = new Set(users.map((u) => u.id.toString()));
const missing = uniqueIds.filter((id) => !foundIds.has(id.toString()));
if (missing.length) {
throw new NotFoundException('部分用户不存在');
}
const items = users.map((u) => {
const unfinishedOrders = u.orders;
const redeemRecords = u.redeemRecords.map((r) => ({
id: r.id,
redeemNo: r.redeemNo,
amount: r.amount,
createdAt: r.createdAt,
payoutStatus: r.payout?.status ?? null,
}));
const hasRisk = unfinishedOrders.length > 0 || redeemRecords.length > 0;
return {
id: u.id,
userNo: u.userNo,
nickname: u.nickname,
phone: u.phone,
unfinishedOrders,
redeemRecords,
hasRisk,
};
});
return serializeBigInt({
items,
hasRisk: items.some((i) => i.hasRisk),
total: items.length,
});
}
async batchDeleteUsers(ids: bigint[], confirmRisk: boolean) {
const preview = await this.previewBatchDelete(ids);
if (preview.hasRisk && !confirmRisk) {
const riskyUsers = preview.items.filter((i) => i.hasRisk);
throw new BadRequestException({
message: '所选用户存在未完成订单或核销记录,需二次确认后删除',
code: 'USER_DELETE_RISK',
riskyUsers: riskyUsers.map((u) => ({
id: u.id,
userNo: u.userNo,
unfinishedOrderCount: u.unfinishedOrders.length,
redeemRecordCount: u.redeemRecords.length,
})),
});
}
const userIds = preview.items.map((i) => BigInt(String(i.id)));
await this.prisma.$transaction(async (tx) => {
for (const id of userIds) {
await this.deleteUserInTx(tx, id);
}
});
return {
ok: true,
deleted: userIds.length,
message: '用户及关联业务数据已删除,行为日志已保留',
};
}
async deleteUser(id: bigint) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
await this.prisma.$transaction(async (tx) => {
const orderIds = (
await tx.order.findMany({ where: { userId: id }, select: { id: true } })
).map((o) => o.id);
const redeemIds = (
await tx.redeemRecord.findMany({ where: { userId: id }, 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.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.benefitCoupon.deleteMany({ where: { userId: id } });
if (orderIds.length) {
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 } } });
}
if (user.avatarResourceId) {
await tx.commonResource.updateMany({
where: { id: user.avatarResourceId, ownerType: 'USER', ownerId: id },
data: { status: 'DELETED' },
});
}
await tx.user.delete({ where: { id } });
await this.deleteUserInTx(tx, id);
});
return {
@@ -162,4 +226,50 @@ export class AdminUsersService {
message: '用户及关联业务数据已删除,行为日志已保留',
};
}
private async deleteUserInTx(tx: Prisma.TransactionClient, id: bigint) {
const user = await tx.user.findUnique({ where: { id } });
if (!user) return;
await tx.user.updateMany({
where: { mergedIntoUserId: id },
data: { mergedIntoUserId: null },
});
const orderIds = (
await tx.order.findMany({ where: { userId: id }, select: { id: true } })
).map((o) => o.id);
const redeemIds = (
await tx.redeemRecord.findMany({ where: { userId: id }, 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.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await tx.benefitCoupon.deleteMany({ where: { userId: id } });
if (orderIds.length) {
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 } } });
}
if (user.avatarResourceId) {
await tx.commonResource.updateMany({
where: { id: user.avatarResourceId, ownerType: 'USER', ownerId: id },
data: { status: 'DELETED' },
});
}
await tx.user.delete({ where: { id } });
}
}
@@ -1,4 +1,5 @@
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -307,6 +308,96 @@ export class UpdateOrderStatusDto {
status: string;
}
export class BatchDeleteOrdersDto {
@IsArray()
@IsString({ each: true })
ids: string[];
}
export class BatchDeleteUsersDto {
@IsArray()
@IsString({ each: true })
ids: string[];
}
export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto {
@IsBoolean()
confirmRisk: boolean;
}
/** HQ 订单发货(目前仅小飞侠 XFX) */
export class AdminShipOrderDto {
@IsIn(['XFX'])
provider: string;
@IsOptional()
@IsString()
fromName?: string;
@IsOptional()
@IsString()
fromMobile?: string;
@IsOptional()
@IsString()
fromAddress?: string;
@IsOptional()
@IsString()
fromAddressDetail?: string;
@IsOptional()
@IsNumber()
fromLng?: number;
@IsOptional()
@IsNumber()
fromLat?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.01)
weight?: number;
@IsOptional()
@IsIn(['1', '2'])
payMode?: string;
@IsOptional()
@IsString()
remark?: string;
}
export class AdminRedeemDebugCreateTokenDto {
@IsString()
@IsNotEmpty()
userId: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
@IsOptional()
@IsString()
couponId?: string;
@IsOptional()
@IsString()
storeId?: string;
}
export class AdminRedeemDebugStoreTokenDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsNotEmpty()
token: string;
}
export class UpdateDeliveryDto {
@IsOptional()
@IsString()
@@ -33,9 +33,12 @@ import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
import { RedeemModule } from '../redeem/redeem.module';
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
@Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -56,6 +59,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
AdminTicketsController,
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
],
providers: [
AdminDashboardService,
@@ -73,6 +77,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
AdminTicketsService,
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
SuperAdminGuard,
],
})