feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
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 } } });
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,576 +0,0 @@
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
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('无法生成占位手机号,请手动填写');
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
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 } } });
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Body, Controller, Delete, 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 { StoreCategoryService } from '../store/store-category.service';
|
||||
import {
|
||||
CreateStoreCategoryDto,
|
||||
UpdateStoreCategoryDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/store-categories')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreCategoriesController {
|
||||
constructor(private readonly categories: StoreCategoryService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.categories.listTree({ includeDisabled: true, ensure: true });
|
||||
}
|
||||
|
||||
@Get('flat')
|
||||
listFlat() {
|
||||
return this.categories.listFlat({ includeDisabled: true, ensure: true });
|
||||
}
|
||||
|
||||
@Post('ensure-defaults')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_ENSURE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
includeBody: false,
|
||||
})
|
||||
async ensureDefaults() {
|
||||
await this.categories.ensureDefaults();
|
||||
return this.categories.listTree({ includeDisabled: true, ensure: false });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_CREATE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateStoreCategoryDto) {
|
||||
return this.categories.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_UPDATE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreCategoryDto) {
|
||||
return this.categories.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_DELETE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categories.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminStoreLogsService } from './admin-store-logs.service';
|
||||
import { AdminStoreLogsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/logs/stores')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreLogsController {
|
||||
constructor(private readonly service: AdminStoreLogsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreLogsQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':source/:rawId')
|
||||
detail(@Param('source') source: string, @Param('rawId') rawId: string) {
|
||||
return this.service.detail(`${source}:${rawId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
eventNamesForStoreLogCategory,
|
||||
resolveStoreLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminStoreLogsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
type StoreLogItem = {
|
||||
id: string;
|
||||
source: 'analytics' | 'redeem_record' | 'store_payout';
|
||||
storeId: string;
|
||||
storeAccountId: string | null;
|
||||
storeName: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
category: ReturnType<typeof resolveStoreLogCategory>;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoreLogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminStoreLogsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const storeIds = await this.resolveStoreIds(query);
|
||||
if (storeIds && storeIds.length === 0) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
|
||||
const dateFilter = this.buildDateFilter(query);
|
||||
const categoryEvents = query.eventName
|
||||
? [query.eventName]
|
||||
: query.category
|
||||
? eventNamesForStoreLogCategory(query.category)
|
||||
: undefined;
|
||||
|
||||
const includeRedeem = !query.category || query.category === 'redeem';
|
||||
const includePayout = !query.category || query.category === 'payout';
|
||||
|
||||
const fetchLimit = page * pageSize;
|
||||
|
||||
const [analyticsRows, redeemRows, payoutRows] = await Promise.all([
|
||||
this.fetchAnalyticsRows({
|
||||
storeIds,
|
||||
categoryEvents,
|
||||
dateFilter,
|
||||
limit: fetchLimit,
|
||||
}),
|
||||
includeRedeem
|
||||
? this.fetchRedeemRows({ storeIds, dateFilter, limit: fetchLimit })
|
||||
: Promise.resolve([]),
|
||||
includePayout
|
||||
? this.fetchPayoutRows({ storeIds, dateFilter, limit: fetchLimit, category: query.category })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const merged = [...analyticsRows, ...redeemRows, ...payoutRows]
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
const items = merged.slice((page - 1) * pageSize, page * pageSize);
|
||||
const total = await this.countTotal({
|
||||
storeIds,
|
||||
categoryEvents,
|
||||
dateFilter,
|
||||
includeRedeem,
|
||||
includePayout,
|
||||
});
|
||||
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(compositeId: string) {
|
||||
const [source, rawId] = compositeId.split(':');
|
||||
if (!source || !rawId) throw new NotFoundException('日志不存在');
|
||||
|
||||
if (source === 'analytics') {
|
||||
const row = await this.prisma.logStoreAnalytics.findUnique({ where: { id: BigInt(rawId) } });
|
||||
if (!row) throw new NotFoundException('日志不存在');
|
||||
const enriched = await this.enrichAnalyticsRows([row]);
|
||||
return serializeBigInt(enriched[0]);
|
||||
}
|
||||
|
||||
if (source === 'redeem_record') {
|
||||
const row = await this.prisma.redeemRecord.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
include: { store: true, user: { select: { id: true, userNo: true, phone: true, nickname: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('日志不存在');
|
||||
return serializeBigInt(this.redeemToItem(row));
|
||||
}
|
||||
|
||||
if (source === 'store_payout') {
|
||||
const row = await this.prisma.storePayout.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
include: { store: true, redeemRecord: { select: { redeemNo: true, amount: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('日志不存在');
|
||||
return serializeBigInt(this.payoutToItem(row));
|
||||
}
|
||||
|
||||
throw new NotFoundException('日志不存在');
|
||||
}
|
||||
|
||||
private buildDateFilter(query: AdminStoreLogsQueryDto): Prisma.DateTimeFilter | undefined {
|
||||
if (!query.from && !query.to) return undefined;
|
||||
return {
|
||||
...(query.from ? { gte: new Date(query.from) } : {}),
|
||||
...(query.to ? { lte: new Date(query.to) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveStoreIds(query: AdminStoreLogsQueryDto): Promise<bigint[] | undefined> {
|
||||
if (query.storeId) return [BigInt(query.storeId)];
|
||||
|
||||
const storeWhere: Prisma.StoreWhereInput = {};
|
||||
if (query.storeName) storeWhere.name = { contains: query.storeName };
|
||||
|
||||
if (query.storeAccountId || query.phone) {
|
||||
const accountWhere: Prisma.StoreAccountWhereInput = {};
|
||||
if (query.storeAccountId) accountWhere.id = BigInt(query.storeAccountId);
|
||||
if (query.phone) accountWhere.phone = { contains: query.phone };
|
||||
const accounts = await this.prisma.storeAccount.findMany({
|
||||
where: accountWhere,
|
||||
select: {
|
||||
bindings: { select: { storeId: true } },
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
if (accounts.length === 0) return [];
|
||||
const ids = [...new Set(accounts.flatMap((a) => a.bindings.map((b) => b.storeId)))];
|
||||
if (storeWhere.name) {
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { id: { in: ids }, ...storeWhere },
|
||||
select: { id: true },
|
||||
});
|
||||
return stores.map((s) => s.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (query.storeName) {
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: storeWhere,
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
return stores.map((s) => s.id);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async fetchAnalyticsRows(input: {
|
||||
storeIds?: bigint[];
|
||||
categoryEvents?: string[];
|
||||
dateFilter?: Prisma.DateTimeFilter;
|
||||
limit: number;
|
||||
}) {
|
||||
const where: Prisma.LogStoreAnalyticsWhereInput = {};
|
||||
if (input.storeIds) where.storeId = { in: input.storeIds };
|
||||
if (input.categoryEvents?.length) where.eventName = { in: input.categoryEvents };
|
||||
if (input.dateFilter) where.createdAt = input.dateFilter;
|
||||
|
||||
const rows = await this.prisma.logStoreAnalytics.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: input.limit,
|
||||
});
|
||||
return this.enrichAnalyticsRows(rows);
|
||||
}
|
||||
|
||||
private async enrichAnalyticsRows(
|
||||
rows: Array<{
|
||||
id: bigint;
|
||||
storeAccountId: bigint | null;
|
||||
storeId: bigint;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: bigint | null;
|
||||
extraJson: unknown;
|
||||
createdAt: Date;
|
||||
}>,
|
||||
): Promise<StoreLogItem[]> {
|
||||
const storeIds = [...new Set(rows.map((r) => r.storeId))];
|
||||
const accountIds = [...new Set(rows.map((r) => r.storeAccountId).filter((id): id is bigint => id != null))];
|
||||
|
||||
const [stores, accounts] = await Promise.all([
|
||||
storeIds.length
|
||||
? this.prisma.store.findMany({ where: { id: { in: storeIds } }, select: { id: true, name: true } })
|
||||
: Promise.resolve([]),
|
||||
accountIds.length
|
||||
? this.prisma.storeAccount.findMany({
|
||||
where: { id: { in: accountIds } },
|
||||
select: { id: true, name: true, phone: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const storeMap = new Map(stores.map((s) => [s.id.toString(), s] as const));
|
||||
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
|
||||
|
||||
return rows.map((row) => {
|
||||
const store = storeMap.get(row.storeId.toString());
|
||||
const account = row.storeAccountId ? accountMap.get(row.storeAccountId.toString()) : undefined;
|
||||
return {
|
||||
id: `analytics:${row.id}`,
|
||||
source: 'analytics' as const,
|
||||
storeId: row.storeId.toString(),
|
||||
storeAccountId: row.storeAccountId?.toString() ?? null,
|
||||
storeName: store?.name ?? null,
|
||||
accountName: account?.name ?? null,
|
||||
accountPhone: account?.phone ?? null,
|
||||
category: resolveStoreLogCategory(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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchRedeemRows(input: {
|
||||
storeIds?: bigint[];
|
||||
dateFilter?: Prisma.DateTimeFilter;
|
||||
limit: number;
|
||||
}) {
|
||||
const where: Prisma.RedeemRecordWhereInput = {};
|
||||
if (input.storeIds) where.storeId = { in: input.storeIds };
|
||||
if (input.dateFilter) where.createdAt = input.dateFilter;
|
||||
|
||||
const rows = await this.prisma.redeemRecord.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: input.limit,
|
||||
include: {
|
||||
store: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((row) => this.redeemToItem(row));
|
||||
}
|
||||
|
||||
private redeemToItem(row: {
|
||||
id: bigint;
|
||||
storeId: bigint;
|
||||
amount: unknown;
|
||||
redeemNo: string;
|
||||
createdAt: Date;
|
||||
store?: { name: string } | null;
|
||||
user?: { id: bigint; userNo: string | null; phone: string | null; nickname: string | null } | null;
|
||||
}): StoreLogItem {
|
||||
return {
|
||||
id: `redeem_record:${row.id}`,
|
||||
source: 'redeem_record',
|
||||
storeId: row.storeId.toString(),
|
||||
storeAccountId: null,
|
||||
storeName: row.store?.name ?? null,
|
||||
accountName: null,
|
||||
accountPhone: null,
|
||||
category: 'redeem',
|
||||
eventName: 'store_redeem_confirm',
|
||||
clientApp: 'SHOP_H5',
|
||||
refType: 'REDEEM_RECORD',
|
||||
refId: row.id.toString(),
|
||||
extraJson: {
|
||||
redeemNo: row.redeemNo,
|
||||
amount: Number(row.amount),
|
||||
userId: row.user?.id.toString(),
|
||||
userNo: row.user?.userNo,
|
||||
userPhone: row.user?.phone,
|
||||
userNickname: row.user?.nickname,
|
||||
legacy: true,
|
||||
},
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchPayoutRows(input: {
|
||||
storeIds?: bigint[];
|
||||
dateFilter?: Prisma.DateTimeFilter;
|
||||
limit: number;
|
||||
category?: string;
|
||||
}) {
|
||||
const where: Prisma.StorePayoutWhereInput = {};
|
||||
if (input.storeIds) where.storeId = { in: input.storeIds };
|
||||
if (input.dateFilter) where.createdAt = input.dateFilter;
|
||||
if (input.category === 'payout') {
|
||||
// include all payout statuses
|
||||
}
|
||||
|
||||
const rows = await this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: input.limit,
|
||||
include: {
|
||||
store: { select: { id: true, name: true } },
|
||||
redeemRecord: { select: { redeemNo: true, amount: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((row) => this.payoutToItem(row));
|
||||
}
|
||||
|
||||
private payoutToItem(row: {
|
||||
id: bigint;
|
||||
storeId: bigint;
|
||||
status: string;
|
||||
payoutAmount: unknown;
|
||||
redeemAmount: unknown;
|
||||
paidAt: Date | null;
|
||||
createdAt: Date;
|
||||
store?: { name: string } | null;
|
||||
redeemRecord?: { redeemNo: string; amount: unknown } | null;
|
||||
}): StoreLogItem {
|
||||
const paid = row.status === 'PAID';
|
||||
return {
|
||||
id: `store_payout:${row.id}`,
|
||||
source: 'store_payout',
|
||||
storeId: row.storeId.toString(),
|
||||
storeAccountId: null,
|
||||
storeName: row.store?.name ?? null,
|
||||
accountName: null,
|
||||
accountPhone: null,
|
||||
category: 'payout',
|
||||
eventName: paid ? 'store_payout_paid' : 'store_payout_created',
|
||||
clientApp: paid ? 'HQ_WEB' : null,
|
||||
refType: 'STORE_PAYOUT',
|
||||
refId: row.id.toString(),
|
||||
extraJson: {
|
||||
status: row.status,
|
||||
payoutAmount: Number(row.payoutAmount),
|
||||
redeemAmount: Number(row.redeemAmount),
|
||||
redeemNo: row.redeemRecord?.redeemNo,
|
||||
paidAt: row.paidAt?.toISOString() ?? null,
|
||||
legacy: true,
|
||||
},
|
||||
createdAt: paid && row.paidAt ? row.paidAt : row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async countTotal(input: {
|
||||
storeIds?: bigint[];
|
||||
categoryEvents?: string[];
|
||||
dateFilter?: Prisma.DateTimeFilter;
|
||||
includeRedeem: boolean;
|
||||
includePayout: boolean;
|
||||
}) {
|
||||
const redeemWhere: Prisma.RedeemRecordWhereInput = {};
|
||||
const payoutWhere: Prisma.StorePayoutWhereInput = {};
|
||||
const analyticsWhere: Prisma.LogStoreAnalyticsWhereInput = {};
|
||||
if (input.storeIds) {
|
||||
redeemWhere.storeId = { in: input.storeIds };
|
||||
payoutWhere.storeId = { in: input.storeIds };
|
||||
analyticsWhere.storeId = { in: input.storeIds };
|
||||
}
|
||||
if (input.dateFilter) {
|
||||
redeemWhere.createdAt = input.dateFilter;
|
||||
payoutWhere.createdAt = input.dateFilter;
|
||||
analyticsWhere.createdAt = input.dateFilter;
|
||||
}
|
||||
if (input.categoryEvents?.length) analyticsWhere.eventName = { in: input.categoryEvents };
|
||||
|
||||
const [analyticsCount, redeemCount, payoutCount] = await Promise.all([
|
||||
this.prisma.logStoreAnalytics.count({ where: analyticsWhere }),
|
||||
input.includeRedeem ? this.prisma.redeemRecord.count({ where: redeemWhere }) : 0,
|
||||
input.includePayout ? this.prisma.storePayout.count({ where: payoutWhere }) : 0,
|
||||
]);
|
||||
|
||||
return analyticsCount + redeemCount + payoutCount;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminStoreRatingsService } from './admin-store-ratings.service';
|
||||
import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/store-ratings')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreRatingsController {
|
||||
constructor(private readonly service: AdminStoreRatingsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreRatingsQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Injectable } 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 { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoreRatingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminStoreRatingsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreRatingWhereInput = {};
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.redeemNo) {
|
||||
where.redeemRecord = { redeemNo: { contains: query.redeemNo } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeRating.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true } },
|
||||
redeemRecord: {
|
||||
select: {
|
||||
id: true,
|
||||
redeemNo: true,
|
||||
amount: true,
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.storeRating.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((r) => ({
|
||||
id: r.id,
|
||||
serviceScore: r.serviceScore,
|
||||
envScore: r.envScore,
|
||||
createdAt: r.createdAt,
|
||||
store: r.store,
|
||||
redeemRecordId: r.redeemRecordId,
|
||||
redeemNo: r.redeemRecord.redeemNo,
|
||||
redeemAmount: Number(r.redeemRecord.amount),
|
||||
user: r.redeemRecord.user,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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 { AdminStoresService } from './admin-stores.service';
|
||||
import {
|
||||
AdminStoreAccountsQueryDto,
|
||||
AdminStoreMediaQueryDto,
|
||||
AdminStoresQueryDto,
|
||||
} from './dto/admin-query.dto';
|
||||
import {
|
||||
CreateStoreAccountDto,
|
||||
CreateStoreDto,
|
||||
CreateStoreMediaDto,
|
||||
UpdateStoreAccountDto,
|
||||
UpdateStoreDto,
|
||||
UpdateStoreMediaDto,
|
||||
UpdateStoreStatusDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/stores')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoresController {
|
||||
constructor(private readonly service: AdminStoresService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoresQueryDto) {
|
||||
return this.service.listStores(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detailStore(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({ action: HqOperationAction.STORE_CREATE, refType: 'STORE', refIdField: 'id', includeBody: true })
|
||||
create(@Body() dto: CreateStoreDto) {
|
||||
return this.service.createStore(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({ action: HqOperationAction.STORE_UPDATE, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreDto) {
|
||||
return this.service.updateStore(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||
return this.service.updateStoreStatus(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
@HqOperation({ action: HqOperationAction.STORE_AUDIT, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
|
||||
return this.service.auditStore(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-accounts')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreAccountsController {
|
||||
constructor(private readonly service: AdminStoresService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreAccountsQueryDto) {
|
||||
return this.service.listStoreAccounts(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detailStoreAccount(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_ACCOUNT_CREATE,
|
||||
refType: 'STORE_ACCOUNT',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateStoreAccountDto) {
|
||||
return this.service.createStoreAccount(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_ACCOUNT_UPDATE,
|
||||
refType: 'STORE_ACCOUNT',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
|
||||
return this.service.updateStoreAccount(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id/staff/:staffId')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_ACCOUNT_STAFF_DELETE,
|
||||
refType: 'STORE_ACCOUNT',
|
||||
refIdParam: 'staffId',
|
||||
})
|
||||
deleteStaff(@Param('id') id: string, @Param('staffId') staffId: string) {
|
||||
return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-media')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreMediaController {
|
||||
constructor(private readonly service: AdminStoresService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreMediaQueryDto) {
|
||||
return this.service.listStoreMedia(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_MEDIA_CREATE,
|
||||
refType: 'STORE_MEDIA',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateStoreMediaDto) {
|
||||
return this.service.createStoreMedia(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_MEDIA_UPDATE,
|
||||
refType: 'STORE_MEDIA',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreMediaDto) {
|
||||
return this.service.updateStoreMedia(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({ action: HqOperationAction.STORE_MEDIA_DELETE, refType: 'STORE_MEDIA', refIdParam: 'id' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.deleteStoreMedia(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,839 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { validateBusinessHours } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { StoreCategoryService } from '../store/store-category.service';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import type {
|
||||
CreateStoreAccountDto,
|
||||
CreateStoreDto,
|
||||
CreateStoreMediaDto,
|
||||
UpdateStoreAccountDto,
|
||||
UpdateStoreDto,
|
||||
UpdateStoreMediaDto,
|
||||
UpdateStoreStatusDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeVisibilityPhones(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;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoresService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreWhereInput = {};
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
|
||||
if (query.auditStatus) {
|
||||
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
|
||||
}
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
cityRef: { select: { id: true, name: true, code: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
category: { select: { id: true, name: true, parentId: true } },
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true, status: true } },
|
||||
},
|
||||
},
|
||||
coverResource: { select: { id: true, url: true } },
|
||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||
},
|
||||
}),
|
||||
this.prisma.store.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((s) => {
|
||||
const { visibilityPhones, ...rest } = s;
|
||||
return mapStoreCompat({
|
||||
...rest,
|
||||
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
|
||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||
partner: s.partnerAccount,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
});
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detailStore(id: bigint) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
cityRef: true,
|
||||
partnerAccount: true,
|
||||
category: true,
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
include: { storeAccount: true },
|
||||
},
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||
_count: { select: { redeemRecords: true, ratings: true } },
|
||||
},
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const [media, audits] = await Promise.all([
|
||||
this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
}),
|
||||
this.prisma.commonEvent.findMany({
|
||||
where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
const { visibilityPhones, ...rest } = store;
|
||||
return serializeBigInt(mapStoreCompat({
|
||||
...rest,
|
||||
visibilityWhitelistEnabled: store.visibilityWhitelistEnabled,
|
||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||
partner: store.partnerAccount,
|
||||
account: store.bindings[0]?.storeAccount ?? null,
|
||||
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
|
||||
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
|
||||
bindings: undefined,
|
||||
media,
|
||||
audits,
|
||||
redeemCount: store._count.redeemRecords,
|
||||
ratingCount: store._count.ratings,
|
||||
_count: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||||
});
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
|
||||
// 允许对已通过门店再次驳回/通过(总部纠错);PENDING/REJECTED/APPROVED 均可审核
|
||||
}
|
||||
if (!dto.approved) {
|
||||
const reason = dto.remark?.trim();
|
||||
if (!reason) throw new BadRequestException('驳回时必须填写原因');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: dto.approved
|
||||
? {
|
||||
// 审核通过后保持闭店,由合伙人自行开门
|
||||
status: store.status === 'CLOSED' ? 'CLOSED' : 'PAUSED',
|
||||
auditStatus: 'APPROVED',
|
||||
rejectReason: null,
|
||||
auditedAt: now,
|
||||
}
|
||||
: {
|
||||
status: 'PAUSED',
|
||||
auditStatus: 'REJECTED',
|
||||
rejectReason: dto.remark!.trim(),
|
||||
auditedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
refType: 'STORE',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
||||
remark: dto.approved
|
||||
? (dto.remark?.trim() || '审核通过,可开门营业')
|
||||
: dto.remark!.trim(),
|
||||
param1: dto.approved ? 'APPROVE' : 'REJECT',
|
||||
param1Desc: 'audit_action',
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.partnerAccountId) {
|
||||
this.analyticsService.trackPartnerOneSafe(undefined, 'HQ_WEB', {
|
||||
partnerAccountId: updated.partnerAccountId,
|
||||
eventName: dto.approved ? 'partner_store_audit_approved' : 'partner_store_audit_rejected',
|
||||
refType: 'STORE',
|
||||
refId: id,
|
||||
extraJson: {
|
||||
storeId: id.toString(),
|
||||
remark: dto.remark?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
...updated,
|
||||
notifyHint: dto.approved
|
||||
? '已通过审核,合伙人可在端内开门营业'
|
||||
: '已驳回,驳回原因已同步至合伙人端',
|
||||
});
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
const current = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('门店不存在');
|
||||
|
||||
const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || '';
|
||||
const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || '';
|
||||
const openTime2 =
|
||||
dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || '';
|
||||
const closeTime2 =
|
||||
dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || '';
|
||||
if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) {
|
||||
const hoursCheck = validateBusinessHours([
|
||||
{ open: openTime || '10:00', close: closeTime || '22:00' },
|
||||
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||||
]);
|
||||
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||||
}
|
||||
|
||||
const latitude = dto.latitude !== undefined ? (dto.latitude == null ? null : Number(dto.latitude)) : undefined;
|
||||
const longitude = dto.longitude !== undefined ? (dto.longitude == null ? null : Number(dto.longitude)) : undefined;
|
||||
if (latitude !== undefined || longitude !== undefined) {
|
||||
if (latitude == null || longitude == null) {
|
||||
throw new BadRequestException('经纬度须同时提供');
|
||||
}
|
||||
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||||
throw new BadRequestException('纬度无效');
|
||||
}
|
||||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
||||
throw new BadRequestException('经度无效');
|
||||
}
|
||||
}
|
||||
|
||||
let categoryId: bigint | undefined;
|
||||
if (dto.categoryId !== undefined) {
|
||||
if (!dto.categoryId?.trim()) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
categoryId = BigInt(dto.categoryId);
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
}
|
||||
|
||||
if (dto.phone !== undefined) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
|
||||
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
|
||||
const nextEnabled =
|
||||
dto.visibilityWhitelistEnabled !== undefined
|
||||
? !!dto.visibilityWhitelistEnabled
|
||||
: current.visibilityWhitelistEnabled;
|
||||
if (nextEnabled) {
|
||||
const phones =
|
||||
dto.visibilityPhones !== undefined
|
||||
? normalizeVisibilityPhones(dto.visibilityPhones)
|
||||
: (
|
||||
await this.prisma.storeVisibilityPhone.findMany({
|
||||
where: { storeId: id },
|
||||
select: { phone: true },
|
||||
})
|
||||
).map((p) => p.phone);
|
||||
if (!phones.length) {
|
||||
throw new BadRequestException('开启白名单时请至少添加一个手机号');
|
||||
}
|
||||
}
|
||||
}
|
||||
const bankTouched =
|
||||
dto.bankAccountName !== undefined ||
|
||||
dto.bankAccountNo !== undefined ||
|
||||
dto.bankBranch !== undefined;
|
||||
const needAccountSync =
|
||||
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
|
||||
|
||||
// 登录凭证在 store_account.phone;必须与门店展示手机号同步
|
||||
let primaryBinding: {
|
||||
storeAccount: { id: bigint; phone: string; name: string } | null;
|
||||
} | null = null;
|
||||
if (needAccountSync) {
|
||||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: { storeId: id, storeAccount: { isPrimary: 1 } },
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!primaryBinding) {
|
||||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||||
where: { storeId: id },
|
||||
include: {
|
||||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||||
},
|
||||
orderBy: { storeAccountId: 'asc' },
|
||||
});
|
||||
}
|
||||
if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) {
|
||||
throw new BadRequestException('门店未绑定登录账号,无法修改手机号');
|
||||
}
|
||||
const primaryAccount = primaryBinding?.storeAccount;
|
||||
if (
|
||||
normalizedPhone !== undefined &&
|
||||
primaryAccount &&
|
||||
normalizedPhone !== primaryAccount.phone
|
||||
) {
|
||||
const occupied = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (occupied && occupied.id !== primaryAccount.id) {
|
||||
throw new BadRequestException('该手机号已被其他门店账号使用');
|
||||
}
|
||||
}
|
||||
if (dto.bankAccountNo !== undefined) {
|
||||
const no = dto.bankAccountNo?.trim() || null;
|
||||
if (no && !/^\d{16,19}$/.test(no)) {
|
||||
throw new BadRequestException('银行卡号须为 16–19 位数字');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.store.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||
...(dto.benefitUsageRule !== undefined
|
||||
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
|
||||
: {}),
|
||||
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||||
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
|
||||
...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}),
|
||||
...(dto.district !== undefined ? { district: dto.district.trim() } : {}),
|
||||
...(categoryId !== undefined ? { categoryId } : {}),
|
||||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||||
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
|
||||
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
|
||||
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
|
||||
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
|
||||
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
|
||||
...(dto.visibilityWhitelistEnabled !== undefined
|
||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||
: {}),
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.visibilityPhones !== undefined) {
|
||||
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
|
||||
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
|
||||
if (phones.length) {
|
||||
await tx.storeVisibilityPhone.createMany({
|
||||
data: phones.map((phone) => ({ storeId: id, phone })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.coverUrl) {
|
||||
if (current.coverResourceId) {
|
||||
await tx.commonResource.update({
|
||||
where: { id: current.coverResourceId },
|
||||
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
|
||||
});
|
||||
} else {
|
||||
const cover = await tx.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: id,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.coverUrl,
|
||||
url: dto.coverUrl,
|
||||
},
|
||||
});
|
||||
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryBinding?.storeAccount) {
|
||||
const account = primaryBinding.storeAccount;
|
||||
const accountData: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
} = {};
|
||||
if (dto.name !== undefined) accountData.name = dto.name.trim();
|
||||
if (normalizedPhone !== undefined && normalizedPhone !== account.phone) {
|
||||
accountData.phone = normalizedPhone;
|
||||
}
|
||||
if (dto.bankAccountName !== undefined) {
|
||||
accountData.bankAccountName = dto.bankAccountName?.trim() || null;
|
||||
}
|
||||
if (dto.bankAccountNo !== undefined) {
|
||||
accountData.bankAccountNo = dto.bankAccountNo?.trim() || null;
|
||||
}
|
||||
if (dto.bankBranch !== undefined) {
|
||||
accountData.bankBranch = dto.bankBranch?.trim() || null;
|
||||
}
|
||||
if (Object.keys(accountData).length) {
|
||||
await tx.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: accountData,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.detailStore(id);
|
||||
}
|
||||
|
||||
async createStore(dto: CreateStoreDto) {
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
if (existingAccount && existingAccount.isPrimary !== 1) {
|
||||
throw new BadRequestException('该手机号已是门店子账号');
|
||||
}
|
||||
if (existingAccount && existingAccount.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('该手机号对应门店账号已停用');
|
||||
}
|
||||
|
||||
const partnerAccountId = BigInt(dto.partnerAccountId);
|
||||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
if (!partnerAccount || partnerAccount.isPrimary !== 1) {
|
||||
throw new BadRequestException('开城合伙人不存在');
|
||||
}
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
|
||||
if (!city) throw new BadRequestException('开城城市不存在');
|
||||
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
||||
|
||||
if (!dto.categoryId?.trim()) {
|
||||
throw new BadRequestException('请选择门店分类');
|
||||
}
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
|
||||
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
|
||||
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
|
||||
if ((latitude == null) !== (longitude == null)) {
|
||||
throw new BadRequestException('经纬度须同时提供');
|
||||
}
|
||||
if (
|
||||
latitude != null &&
|
||||
(!Number.isFinite(latitude) || !Number.isFinite(longitude!) || latitude < -90 || latitude > 90)
|
||||
) {
|
||||
throw new BadRequestException('经纬度无效');
|
||||
}
|
||||
|
||||
const openTime = dto.openTime?.trim() || '10:00';
|
||||
const closeTime = dto.closeTime?.trim() || '22:00';
|
||||
const openTime2 = dto.openTime2?.trim() || '';
|
||||
const closeTime2 = dto.closeTime2?.trim() || '';
|
||||
const hoursCheck = validateBusinessHours([
|
||||
{ open: openTime, close: closeTime },
|
||||
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||||
]);
|
||||
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||||
|
||||
const intro = dto.intro?.trim() || null;
|
||||
if (intro && (intro.length < 2 || intro.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
|
||||
if (benefitUsageRule && benefitUsageRule.length > 1000) {
|
||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||
}
|
||||
|
||||
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
|
||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||
if (whitelistEnabled && !visibilityPhones.length) {
|
||||
throw new BadRequestException('开启白名单时请至少添加一个手机号');
|
||||
}
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerAccountId,
|
||||
settlementRate: dto.settlementRate ?? 0.6,
|
||||
categoryId,
|
||||
name: dto.name,
|
||||
phone: normalizedPhone,
|
||||
province: dto.province ?? city.province,
|
||||
cityName: dto.city ?? city.name,
|
||||
district: dto.district ?? '',
|
||||
address: dto.address,
|
||||
intro,
|
||||
benefitUsageRule,
|
||||
avgPrice: dto.avgPrice ?? null,
|
||||
openTime,
|
||||
closeTime,
|
||||
openTime2: openTime2 || null,
|
||||
closeTime2: closeTime2 || null,
|
||||
visibilityWhitelistEnabled: whitelistEnabled,
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
status: 'OPEN',
|
||||
auditStatus: 'APPROVED',
|
||||
auditedAt: new Date(),
|
||||
rejectReason: null,
|
||||
...(visibilityPhones.length
|
||||
? {
|
||||
visibilityPhones: {
|
||||
create: visibilityPhones.map((phone) => ({ phone })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.coverUrl) {
|
||||
const cover = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.coverUrl,
|
||||
url: dto.coverUrl,
|
||||
},
|
||||
});
|
||||
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||
}
|
||||
|
||||
const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
|
||||
for (let i = 0; i < envUrls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'ENV',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: envUrls[i],
|
||||
url: envUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.contractUrl) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: 'FILE',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.contractUrl,
|
||||
url: dto.contractUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
refType: 'STORE',
|
||||
refId: store.id,
|
||||
actorType: 'HQ',
|
||||
status: 'APPROVED',
|
||||
param1: 'NEW',
|
||||
param1Desc: 'audit_type',
|
||||
remark: 'HQ 后台新建',
|
||||
},
|
||||
});
|
||||
|
||||
const bankAccountName = dto.bankAccountName ?? null;
|
||||
const bankAccountNo = dto.bankAccountNo ?? null;
|
||||
const bankBranch = dto.bankBranch ?? null;
|
||||
|
||||
if (existingAccount) {
|
||||
await this.prisma.storeAccountStore.create({
|
||||
data: { storeAccountId: existingAccount.id, storeId: store.id },
|
||||
});
|
||||
if (bankAccountName || bankAccountNo || bankBranch) {
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: existingAccount.id },
|
||||
data: {
|
||||
...(bankAccountName != null ? { bankAccountName } : {}),
|
||||
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||||
...(bankBranch != null ? { bankBranch } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
name: dto.accountName ?? dto.name,
|
||||
isPrimary: 1,
|
||||
bankAccountName,
|
||||
bankAccountNo,
|
||||
bankBranch,
|
||||
bindings: { create: [{ storeId: store.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.detailStore(store.id);
|
||||
}
|
||||
|
||||
async createStoreAccount(dto: CreateStoreAccountDto) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: BigInt(dto.storeId) },
|
||||
include: { bindings: true },
|
||||
});
|
||||
if (!store) throw new BadRequestException('门店不存在');
|
||||
const primaryBound = store.bindings.length > 0
|
||||
? await this.prisma.storeAccount.findFirst({
|
||||
where: {
|
||||
isPrimary: 1,
|
||||
bindings: { some: { storeId: store.id } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (primaryBound) throw new BadRequestException('门店已有主账号绑定');
|
||||
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } });
|
||||
if (existing) {
|
||||
if (existing.isPrimary !== 1) {
|
||||
throw new BadRequestException('该手机号已是门店子账号');
|
||||
}
|
||||
await this.prisma.storeAccountStore.create({
|
||||
data: { storeAccountId: existing.id, storeId: store.id },
|
||||
});
|
||||
return serializeBigInt(existing);
|
||||
}
|
||||
|
||||
const account = await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
phone: dto.phone,
|
||||
name: dto.name,
|
||||
isPrimary: 1,
|
||||
bindings: { create: [{ storeId: store.id }] },
|
||||
},
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
async listStoreMedia(query: AdminStoreMediaQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonResourceWhereInput = {
|
||||
ownerType: 'STORE',
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
if (query.storeId) where.ownerId = BigInt(query.storeId);
|
||||
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['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 createStoreMedia(dto: CreateStoreMediaDto) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
|
||||
if (!store) throw new BadRequestException('门店不存在');
|
||||
const media = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'ENV',
|
||||
mediaType: dto.mediaType as 'IMAGE' | 'VIDEO',
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.url,
|
||||
url: dto.url,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(media);
|
||||
}
|
||||
|
||||
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
|
||||
const media = 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' } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(media);
|
||||
}
|
||||
|
||||
async deleteStoreMedia(id: bigint) {
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.storeId) {
|
||||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||||
}
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeAccount.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: { select: { id: true, name: true, status: true, cityName: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { childAccounts: true, bindings: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storeAccount.count({ where }),
|
||||
]);
|
||||
const mapped = items.map((row) => ({
|
||||
...row,
|
||||
storeCount: row._count.bindings,
|
||||
staffCount: row._count.childAccounts,
|
||||
stores: row.bindings.map((b) => b.store),
|
||||
store: row.bindings[0]?.store ?? null,
|
||||
}));
|
||||
return serializeBigInt({ items: mapped, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detailStoreAccount(id: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: { include: { cityRef: true, partnerAccount: true } },
|
||||
},
|
||||
},
|
||||
childAccounts: {
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: { select: { id: true, name: true, status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
return serializeBigInt({
|
||||
...account,
|
||||
stores: account.bindings.map((b) => b.store),
|
||||
store: account.bindings[0]?.store ?? null,
|
||||
staff: account.childAccounts,
|
||||
});
|
||||
}
|
||||
|
||||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
|
||||
const account = await this.prisma.storeAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
/** HQ 删除门店子账号(非主账号) */
|
||||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
|
||||
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
|
||||
if (!parent || parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('主账号不存在');
|
||||
}
|
||||
const staff = await this.prisma.storeAccount.findFirst({
|
||||
where: { id: staffId, parentAccountId, isPrimary: 0 },
|
||||
});
|
||||
if (!staff) throw new NotFoundException('子账号不存在');
|
||||
|
||||
const pending = await this.prisma.redeemPendingRecord.count({
|
||||
where: { storeAccountId: staffId },
|
||||
});
|
||||
if (pending > 0) {
|
||||
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
|
||||
}
|
||||
|
||||
await this.prisma.storeAccount.delete({ where: { id: staffId } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.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 { SupportTicketService } from '../common/support-ticket.service';
|
||||
import {
|
||||
CreateSupportTicketDto,
|
||||
RejectSupportTicketDto,
|
||||
SupportTicketListQueryDto,
|
||||
SupportTicketRemarkDto,
|
||||
} from '../common/dto/support-ticket.dto';
|
||||
|
||||
@Controller('admin/support-tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminSupportTicketsController {
|
||||
constructor(
|
||||
private readonly service: SupportTicketService,
|
||||
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()
|
||||
list(@Query() query: SupportTicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_CREATE,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateSupportTicketDto) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.create(body, account);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_APPROVE,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async approve(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: SupportTicketRemarkDto,
|
||||
) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.approve(BigInt(id), account, body);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_REJECT,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async reject(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: RejectSupportTicketDto,
|
||||
) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.reject(BigInt(id), account, body);
|
||||
}
|
||||
|
||||
@Post(':id/start-testing')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_START_TESTING,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
startTesting(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||
return this.service.startTesting(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post(':id/pass')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_PASS,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
pass(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||
return this.service.pass(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { BadRequestException, Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
|
||||
import {
|
||||
SYSTEM_CONFIG_GROUP_PERMISSION,
|
||||
SYSTEM_SETTINGS_PERMISSION_KEYS,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
HqPermissionsResolver,
|
||||
RequireAnySystemSettings,
|
||||
} 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 { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
|
||||
@Controller('admin/system-config')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
export class AdminSystemConfigController {
|
||||
constructor(
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
private readonly permissions: HqPermissionsResolver,
|
||||
private readonly wecomAibot: WecomAibotService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequireAnySystemSettings()
|
||||
async getForm(@CurrentUser() user: AuthUser) {
|
||||
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
|
||||
const allowedGroups = allowedConfigGroups(keys);
|
||||
return this.systemConfig.getForm(allowedGroups);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequireAnySystemSettings()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_UPDATE,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
refIdField: 'id',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) {
|
||||
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
|
||||
const allowedGroups = allowedConfigGroups(keys);
|
||||
const result = await this.systemConfig.update(dto, allowedGroups);
|
||||
if (result.updatedKeys.includes('WECOM_AIBOT_ENABLED')) {
|
||||
const wecomStatus = await this.wecomAibot.reload('system-config');
|
||||
return { ...result, wecomAibot: wecomStatus };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('sync-env')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_SYNC_ENV,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
batch: true,
|
||||
})
|
||||
syncEnv() {
|
||||
return this.systemConfig.syncToEnvFile();
|
||||
}
|
||||
|
||||
@Post('import-env')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
batch: true,
|
||||
})
|
||||
importEnv() {
|
||||
return this.systemConfig.importFromProcessEnv();
|
||||
}
|
||||
|
||||
/** 向企微群机器人发送一条测试告警 */
|
||||
@Post('wecom-alert/test')
|
||||
@RequireAnySystemSettings()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_ALERT_TEST,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
batch: true,
|
||||
})
|
||||
async testWecomAlert() {
|
||||
const result = await this.alert.sendTestAlert();
|
||||
if (!result.ok) {
|
||||
throw new BadRequestException(result.message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
|
||||
if (SYSTEM_SETTINGS_PERMISSION_KEYS.every((k) => permissionKeys.includes(k))) {
|
||||
return null;
|
||||
}
|
||||
return Object.entries(SYSTEM_CONFIG_GROUP_PERMISSION)
|
||||
.filter(([, perm]) => permissionKeys.includes(perm))
|
||||
.map(([group]) => group);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
|
||||
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 { AdminTicketsService } from './admin-tickets.service';
|
||||
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
import { AdminCreateTicketDto } from '../common/dto/common-mutate.dto';
|
||||
|
||||
@Controller('admin/tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminTicketsController {
|
||||
constructor(private readonly service: AdminTicketsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: TicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.TICKET_CREATE,
|
||||
refType: 'TICKET',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() body: AdminCreateTicketDto) {
|
||||
return this.service.createByHq(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.TICKET_APPROVE,
|
||||
refType: 'TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
approve(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { remark?: string },
|
||||
) {
|
||||
return this.service.approve(BigInt(id), body.remark, user.actorId.toString());
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.TICKET_REJECT,
|
||||
refType: 'TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.reject(BigInt(id), body.remark);
|
||||
}
|
||||
|
||||
@Post(':id/complete-collab')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.TICKET_APPROVE,
|
||||
refType: 'TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
completeCollab(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.completeCollabByHq(BigInt(id), user.actorId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/tickets')
|
||||
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
||||
@RequirePartnerPermissions('warehouse:manage', 'order:view')
|
||||
export class PartnerTicketsController {
|
||||
constructor(private readonly service: AdminTicketsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listPartnerTickets(
|
||||
user.actorId,
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.getPartnerTicket(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm-pickup')
|
||||
@RequirePartnerPermissions('warehouse:manage')
|
||||
confirmPickup(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.partnerConfirmPickup(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm-reship')
|
||||
@RequirePartnerPermissions('warehouse:manage')
|
||||
confirmReship(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.partnerConfirmReship(user.actorId, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
export type CollabPhase = 'AWAITING_PICKUP' | 'AWAITING_RESHIP' | 'HQ_DIRECT';
|
||||
|
||||
export type TicketCollabExtra = {
|
||||
evidenceUrls?: string[];
|
||||
warehouseId?: string;
|
||||
warehouseName?: string;
|
||||
warehousePartnerAccountId?: string;
|
||||
storePartnerAccountId?: string;
|
||||
collabPhase?: CollabPhase;
|
||||
collabLogs?: { at: string; actorType: string; actorId: string; action: string }[];
|
||||
};
|
||||
|
||||
const COLLAB_TYPES = ['RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class AdminTicketsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
list(query: TicketListQueryDto) {
|
||||
return this.ticketService.list(query);
|
||||
}
|
||||
|
||||
detail(id: bigint) {
|
||||
return this.ticketService.detail(id);
|
||||
}
|
||||
|
||||
async createByHq(body: {
|
||||
ticketType: string;
|
||||
orderNo: string;
|
||||
remark?: string;
|
||||
evidenceUrls?: string[];
|
||||
}) {
|
||||
const orderNo = body.orderNo?.trim();
|
||||
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
|
||||
throw new BadRequestException('当前订单不可创建工单');
|
||||
}
|
||||
if (['REFUNDING', 'REFUNDED'].includes(order.status) && body.ticketType !== 'ALERT') {
|
||||
throw new BadRequestException('订单已在退款流程中');
|
||||
}
|
||||
|
||||
const pending = await this.prisma.commonTicket.findFirst({
|
||||
where: {
|
||||
ticketType: body.ticketType as never,
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: { in: ['PENDING', 'OPEN'] },
|
||||
},
|
||||
});
|
||||
if (pending) throw new BadRequestException('该类型工单已在处理中');
|
||||
|
||||
const evidenceUrls = (body.evidenceUrls ?? []).filter((u) => typeof u === 'string' && u.trim());
|
||||
return this.ticketService.create({
|
||||
ticketType: body.ticketType,
|
||||
refType: 'ORDER',
|
||||
refId: order.id.toString(),
|
||||
remark: body.remark ?? '',
|
||||
extraJson: evidenceUrls.length ? { evidenceUrls } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private parseExtra(raw: unknown): TicketCollabExtra {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return raw as TicketCollabExtra;
|
||||
}
|
||||
|
||||
private appendLog(
|
||||
extra: TicketCollabExtra,
|
||||
actorType: string,
|
||||
actorId: string,
|
||||
action: string,
|
||||
): TicketCollabExtra {
|
||||
const logs = [...(extra.collabLogs ?? [])];
|
||||
logs.push({ at: new Date().toISOString(), actorType, actorId, action });
|
||||
return { ...extra, collabLogs: logs };
|
||||
}
|
||||
|
||||
/** 解析订单负责仓:优先履约仓,否则城内首个 ACTIVE 仓 */
|
||||
async resolveWarehouseForOrder(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
cityId: true,
|
||||
fulfillmentWarehouseId: true,
|
||||
partnerAccountIdAtPay: true,
|
||||
orderNo: true,
|
||||
receiverAddress: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
productName: true,
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('关联订单不存在');
|
||||
|
||||
let warehouse = order.fulfillmentWarehouseId
|
||||
? await this.prisma.cityWarehouse.findUnique({ where: { id: order.fulfillmentWarehouseId } })
|
||||
: null;
|
||||
if (!warehouse) {
|
||||
warehouse = await this.prisma.cityWarehouse.findFirst({
|
||||
where: { cityId: order.cityId, status: 'ACTIVE' },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
}
|
||||
return { order, warehouse };
|
||||
}
|
||||
|
||||
private async executeRefund(orderId: bigint, ticketId: bigint, remark: string) {
|
||||
await this.tradeService.initiateRefund(orderId, ticketId, remark, 'HQ');
|
||||
}
|
||||
|
||||
private async executeReship(orderId: bigint) {
|
||||
await this.tradeService.applyStatusTransition(orderId, 'PENDING_SHIP', 'PENDING_SHIP', 'HQ');
|
||||
}
|
||||
|
||||
async approve(id: bigint, remark?: string, actorId = '0') {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('工单不存在');
|
||||
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
|
||||
throw new BadRequestException('工单状态不可审批');
|
||||
}
|
||||
if (ticket.refType !== 'ORDER') {
|
||||
throw new BadRequestException('仅支持订单售后工单');
|
||||
}
|
||||
|
||||
const existingExtra = this.parseExtra(ticket.extraJson);
|
||||
|
||||
// 仅退款:立即退款
|
||||
if (ticket.ticketType === 'REFUND') {
|
||||
await this.executeRefund(ticket.refId, ticket.id, remark ?? '仅退款工单审批通过');
|
||||
return this.ticketService.updateExtraJson(
|
||||
id,
|
||||
this.appendLog(existingExtra, 'HQ', actorId, 'APPROVE_REFUND'),
|
||||
'RESOLVED',
|
||||
remark ?? '审批通过',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(COLLAB_TYPES as readonly string[]).includes(ticket.ticketType)) {
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'RESOLVED',
|
||||
remark: remark ?? '审批通过',
|
||||
});
|
||||
}
|
||||
|
||||
const { order, warehouse } = await this.resolveWarehouseForOrder(ticket.refId);
|
||||
const warehousePartnerAccountId =
|
||||
warehouse?.partnerAccountId?.toString() ||
|
||||
(warehouse
|
||||
? (
|
||||
await this.prisma.partnerAccount.findFirst({
|
||||
where: { managedWarehouseId: warehouse.id },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id.toString()
|
||||
: undefined);
|
||||
|
||||
let collabPhase: CollabPhase = warehousePartnerAccountId ? 'AWAITING_PICKUP' : 'HQ_DIRECT';
|
||||
if (ticket.ticketType === 'RESHIPMENT' && warehousePartnerAccountId) {
|
||||
collabPhase = 'AWAITING_RESHIP';
|
||||
}
|
||||
if (!warehouse) {
|
||||
collabPhase = 'HQ_DIRECT';
|
||||
}
|
||||
|
||||
const storePartnerAccountId =
|
||||
ticket.ticketType === 'RETURN_REFUND' && order.partnerAccountIdAtPay
|
||||
? order.partnerAccountIdAtPay.toString()
|
||||
: undefined;
|
||||
|
||||
let extra: TicketCollabExtra = {
|
||||
...existingExtra,
|
||||
warehouseId: warehouse?.id.toString(),
|
||||
warehouseName: warehouse?.name,
|
||||
warehousePartnerAccountId,
|
||||
storePartnerAccountId,
|
||||
collabPhase,
|
||||
};
|
||||
extra = this.appendLog(extra, 'HQ', actorId, `APPROVE_COLLAB:${collabPhase}`);
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'TICKET_COLLAB',
|
||||
refType: 'TICKET',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'COLLABORATING',
|
||||
remark: remark ?? `工单进入协同 ${collabPhase}`,
|
||||
},
|
||||
});
|
||||
|
||||
return this.ticketService.updateExtraJson(id, extra as Record<string, unknown>, 'COLLABORATING', remark ?? '审批通过,待协同');
|
||||
}
|
||||
|
||||
reject(id: bigint, remark?: string) {
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'REJECTED',
|
||||
remark: remark ?? '审批驳回',
|
||||
});
|
||||
}
|
||||
|
||||
/** 完成协同:取回确认(退货类)或补发确认 */
|
||||
async completeCollab(
|
||||
id: bigint,
|
||||
opts: {
|
||||
actorType: 'HQ' | 'PARTNER';
|
||||
actorId: string;
|
||||
mode: 'pickup' | 'reship' | 'auto';
|
||||
},
|
||||
) {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('工单不存在');
|
||||
if (ticket.status !== 'COLLABORATING') {
|
||||
throw new BadRequestException('工单不在协同中');
|
||||
}
|
||||
if (ticket.refType !== 'ORDER') throw new BadRequestException('仅支持订单售后工单');
|
||||
|
||||
let extra = this.parseExtra(ticket.extraJson);
|
||||
const phase = extra.collabPhase ?? 'HQ_DIRECT';
|
||||
|
||||
if (opts.actorType === 'PARTNER') {
|
||||
// 仅管仓合伙人可操作协同节点;归属合伙人只读
|
||||
if (extra.warehousePartnerAccountId !== opts.actorId) {
|
||||
throw new BadRequestException('无权操作此工单');
|
||||
}
|
||||
}
|
||||
|
||||
const mode =
|
||||
opts.mode === 'auto'
|
||||
? ticket.ticketType === 'RESHIPMENT'
|
||||
? 'reship'
|
||||
: 'pickup'
|
||||
: opts.mode;
|
||||
|
||||
if (mode === 'reship' && ticket.ticketType !== 'RESHIPMENT') {
|
||||
throw new BadRequestException('当前工单不是补发类型');
|
||||
}
|
||||
if (mode === 'pickup' && !['DAMAGE_RETURN', 'RETURN_REFUND'].includes(ticket.ticketType)) {
|
||||
throw new BadRequestException('当前工单不需要取回确认');
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'RESHIPMENT') {
|
||||
await this.executeReship(ticket.refId);
|
||||
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_RESHIP');
|
||||
} else if (ticket.ticketType === 'DAMAGE_RETURN' || ticket.ticketType === 'RETURN_REFUND') {
|
||||
await this.executeRefund(ticket.refId, ticket.id, `${ticket.ticketType} 协同取回后完成退款`);
|
||||
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_PICKUP_REFUND');
|
||||
} else {
|
||||
throw new BadRequestException('工单类型不支持协同完成');
|
||||
}
|
||||
|
||||
extra = { ...extra, collabPhase: undefined };
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'TICKET_COLLAB',
|
||||
refType: 'TICKET',
|
||||
refId: id,
|
||||
actorType: opts.actorType,
|
||||
status: 'RESOLVED',
|
||||
remark: `协同完成 phaseWas=${phase} mode=${mode}`,
|
||||
},
|
||||
});
|
||||
|
||||
return this.ticketService.updateExtraJson(id, extra as Record<string, unknown>, 'RESOLVED', '协同完成');
|
||||
}
|
||||
|
||||
async completeCollabByHq(id: bigint, actorId: string) {
|
||||
return this.completeCollab(id, { actorType: 'HQ', actorId, mode: 'auto' });
|
||||
}
|
||||
|
||||
async listPartnerTickets(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const pid = primary.id.toString();
|
||||
|
||||
const warehouses = await this.prisma.cityWarehouse.findMany({
|
||||
where: {
|
||||
OR: [{ partnerAccountId: primary.id }, { managedBy: { id: primary.id } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const warehouseIds = new Set(warehouses.map((w) => w.id.toString()));
|
||||
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
status: 'COLLABORATING',
|
||||
ticketType: { in: [...COLLAB_TYPES] },
|
||||
refType: 'ORDER',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const filtered = tickets.filter((t) => {
|
||||
const extra = this.parseExtra(t.extraJson);
|
||||
if (extra.warehousePartnerAccountId === pid) return true;
|
||||
if (extra.storePartnerAccountId === pid) return true;
|
||||
if (extra.warehouseId && warehouseIds.has(extra.warehouseId)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const pageItems = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||
const orderIds = pageItems.map((t) => t.refId);
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { id: { in: orderIds } },
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
productName: true,
|
||||
receiverAddress: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
payAmount: true,
|
||||
},
|
||||
});
|
||||
const orderMap = new Map(orders.map((o) => [o.id.toString(), o]));
|
||||
|
||||
return serializeBigInt({
|
||||
items: pageItems.map((t) => {
|
||||
const order = orderMap.get(t.refId.toString());
|
||||
const extra = this.parseExtra(t.extraJson);
|
||||
return {
|
||||
...t,
|
||||
extraJson: extra,
|
||||
orderNo: order?.orderNo,
|
||||
productName: order?.productName,
|
||||
receiverAddress: order?.receiverAddress,
|
||||
receiverName: order?.receiverName,
|
||||
receiverPhone: order?.receiverPhone,
|
||||
payAmount: order?.payAmount,
|
||||
};
|
||||
}),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async getPartnerTicket(partnerAccountId: bigint, ticketId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const pid = primary.id.toString();
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket || ticket.refType !== 'ORDER') throw new NotFoundException('工单不存在或无权查看');
|
||||
const extra = this.parseExtra(ticket.extraJson);
|
||||
const warehouses = await this.prisma.cityWarehouse.findMany({
|
||||
where: {
|
||||
OR: [{ partnerAccountId: primary.id }, { managedBy: { id: primary.id } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const warehouseIds = new Set(warehouses.map((w) => w.id.toString()));
|
||||
const allowed =
|
||||
extra.warehousePartnerAccountId === pid ||
|
||||
extra.storePartnerAccountId === pid ||
|
||||
(extra.warehouseId != null && warehouseIds.has(extra.warehouseId));
|
||||
if (!allowed) throw new NotFoundException('工单不存在或无权查看');
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: ticket.refId },
|
||||
select: {
|
||||
orderNo: true,
|
||||
productName: true,
|
||||
receiverAddress: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
payAmount: true,
|
||||
},
|
||||
});
|
||||
return serializeBigInt({
|
||||
...ticket,
|
||||
extraJson: extra,
|
||||
orderNo: order?.orderNo,
|
||||
productName: order?.productName,
|
||||
receiverAddress: order?.receiverAddress,
|
||||
receiverName: order?.receiverName,
|
||||
receiverPhone: order?.receiverPhone,
|
||||
payAmount: order?.payAmount,
|
||||
});
|
||||
}
|
||||
|
||||
async partnerConfirmPickup(partnerAccountId: bigint, ticketId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
return this.completeCollab(ticketId, {
|
||||
actorType: 'PARTNER',
|
||||
actorId: primary.id.toString(),
|
||||
mode: 'pickup',
|
||||
});
|
||||
}
|
||||
|
||||
async partnerConfirmReship(partnerAccountId: bigint, ticketId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
return this.completeCollab(ticketId, {
|
||||
actorType: 'PARTNER',
|
||||
actorId: primary.id.toString(),
|
||||
mode: 'reship',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
return this.listPartnerTickets(partnerAccountId, 1, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminUserLogsService } from './admin-user-logs.service';
|
||||
import { AdminUserLogsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/logs/users')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminUserLogsController {
|
||||
constructor(private readonly service: AdminUserLogsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminUserLogsQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { eventNamesForUserLogCategory, resolveUserLogCategory } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminUserLogsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminUserLogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminUserLogsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.LogUserAnalyticsWhereInput = {};
|
||||
|
||||
if (query.userId) {
|
||||
where.userId = BigInt(query.userId);
|
||||
} else if (query.phone || query.userNo) {
|
||||
const userWhere: Prisma.UserWhereInput = {};
|
||||
if (query.phone) userWhere.phone = { contains: query.phone };
|
||||
if (query.userNo) userWhere.userNo = { contains: query.userNo };
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: userWhere,
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
if (users.length === 0) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
where.userId = { in: users.map((u) => u.id) };
|
||||
}
|
||||
|
||||
if (query.eventName) {
|
||||
where.eventName = query.eventName;
|
||||
} else if (query.category) {
|
||||
const names = eventNamesForUserLogCategory(query.category);
|
||||
if (names?.length) {
|
||||
where.eventName = { in: names };
|
||||
}
|
||||
}
|
||||
|
||||
if (query.from || query.to) {
|
||||
where.createdAt = {
|
||||
...(query.from ? { gte: new Date(query.from) } : {}),
|
||||
...(query.to ? { lte: new Date(query.to) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.logUserAnalytics.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.logUserAnalytics.count({ where }),
|
||||
]);
|
||||
|
||||
const userIds = [...new Set(rows.map((r) => r.userId).filter((id): id is bigint => id != null))];
|
||||
const users = userIds.length
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, userNo: true, phone: true, nickname: true },
|
||||
})
|
||||
: [];
|
||||
const userMap = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
return serializeBigInt({
|
||||
items: rows.map((row) => {
|
||||
const user = row.userId ? userMap.get(row.userId.toString()) : undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
userNo: user?.userNo ?? null,
|
||||
phone: user?.phone ?? null,
|
||||
nickname: user?.nickname ?? null,
|
||||
category: resolveUserLogCategory(row.eventName),
|
||||
eventName: row.eventName,
|
||||
clientApp: row.clientApp,
|
||||
refType: row.refType,
|
||||
refId: row.refId,
|
||||
extraJson: row.extraJson,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const row = await this.prisma.logUserAnalytics.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('日志不存在');
|
||||
|
||||
const user = row.userId
|
||||
? await this.prisma.user.findUnique({
|
||||
where: { id: row.userId },
|
||||
select: { id: true, userNo: true, phone: true, nickname: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return serializeBigInt({
|
||||
...row,
|
||||
userNo: user?.userNo ?? null,
|
||||
phone: user?.phone ?? null,
|
||||
nickname: user?.nickname ?? null,
|
||||
category: resolveUserLogCategory(row.eventName),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, 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 { 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)
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly usersService: AdminUsersService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminUsersQueryDto) {
|
||||
return this.usersService.list(query);
|
||||
}
|
||||
|
||||
@Post('batch-delete/preview')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
|
||||
return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_BATCH_DELETE,
|
||||
refType: 'USER',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
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));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_DELETE,
|
||||
refType: 'USER',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.deleteUser(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
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;
|
||||
deviceKey: string | null;
|
||||
phone: string | null;
|
||||
phoneVerifiedAt: Date | null;
|
||||
mergedIntoUserId: bigint | null;
|
||||
wxOpenId: string | null;
|
||||
nickname: string | null;
|
||||
status: number;
|
||||
sourceType: string;
|
||||
sourceRefId: bigint | null;
|
||||
sourceLabel: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { orders: number };
|
||||
}) {
|
||||
return {
|
||||
id: u.id,
|
||||
userNo: u.userNo,
|
||||
deviceKey: u.deviceKey,
|
||||
phone: u.phone,
|
||||
phoneVerifiedAt: u.phoneVerifiedAt,
|
||||
mergedIntoUserId: u.mergedIntoUserId,
|
||||
wxOpenId: u.wxOpenId,
|
||||
wechatVerified: !!u.wxOpenId,
|
||||
nickname: u.nickname,
|
||||
status: u.status,
|
||||
sourceType: u.sourceType,
|
||||
sourceRefId: u.sourceRefId,
|
||||
sourceLabel: u.sourceLabel,
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
orderCount: u._count.orders,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminUsersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.userNo) where.userNo = { contains: query.userNo };
|
||||
if (query.deviceKey) where.deviceKey = query.deviceKey;
|
||||
if (query.status !== undefined) where.status = query.status;
|
||||
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
|
||||
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
deviceKey: true,
|
||||
phone: true,
|
||||
phoneVerifiedAt: true,
|
||||
mergedIntoUserId: true,
|
||||
wxOpenId: true,
|
||||
nickname: true,
|
||||
status: true,
|
||||
sourceType: true,
|
||||
sourceRefId: true,
|
||||
sourceLabel: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((u) => mapAdminUserRow(u)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
cityPreference: true,
|
||||
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
orders: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
status: true,
|
||||
payAmount: true,
|
||||
payStatus: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { mergedFrom: true, orders: true, addresses: true } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
|
||||
let sourcePromo: { id: bigint; code: string; name: string } | null = null;
|
||||
if (user.sourceType === 'PROMO_CODE' && user.sourceRefId) {
|
||||
sourcePromo = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id: user.sourceRefId },
|
||||
select: { id: true, code: true, name: true },
|
||||
});
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
...user,
|
||||
wechatVerified: !!user.wxOpenId,
|
||||
mergedFromCount: user._count.mergedFrom,
|
||||
orderCount: user._count.orders,
|
||||
addressCount: user._count.addresses,
|
||||
sourcePromo,
|
||||
_count: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
await this.deleteUserInTx(tx, id);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
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,20 +0,0 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
||||
import { AdminWechatBindingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/wechat-bindings')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminWechatBindingsController {
|
||||
constructor(private readonly wechatBindingsService: AdminWechatBindingsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminWechatBindingsQueryDto) {
|
||||
return this.wechatBindingsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':groupKey')
|
||||
detail(@Param('groupKey') groupKey: string) {
|
||||
return this.wechatBindingsService.detail(decodeURIComponent(groupKey));
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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 { AdminWechatBindingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||
|
||||
type BindingRow = {
|
||||
actorType: ActorType;
|
||||
actorId: bigint;
|
||||
phone: string | null;
|
||||
name: string | null;
|
||||
wxOpenId: string;
|
||||
wxUnionId: string | null;
|
||||
phoneVerified?: boolean;
|
||||
refLabel?: string | null;
|
||||
refId?: bigint | null;
|
||||
lastLoginAt: Date | null;
|
||||
status: string | number;
|
||||
};
|
||||
|
||||
function buildGroupKey(row: BindingRow): string {
|
||||
if (row.wxUnionId) return `union:${row.wxUnionId}`;
|
||||
return `solo:${row.actorType}:${row.actorId.toString()}`;
|
||||
}
|
||||
|
||||
function mapBindingRow(row: BindingRow) {
|
||||
return {
|
||||
actorType: row.actorType,
|
||||
actorId: row.actorId.toString(),
|
||||
phone: row.phone,
|
||||
name: row.name,
|
||||
wxOpenId: row.wxOpenId,
|
||||
wxUnionId: row.wxUnionId,
|
||||
phoneVerified: row.phoneVerified,
|
||||
refLabel: row.refLabel ?? null,
|
||||
refId: row.refId?.toString() ?? null,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeGroup(groupKey: string, rows: BindingRow[]) {
|
||||
const unionId = groupKey.startsWith('union:') ? groupKey.slice('union:'.length) : null;
|
||||
const actorTypes = [...new Set(rows.map((r) => r.actorType))];
|
||||
const phones = rows.map((r) => r.phone).filter((p): p is string => !!p);
|
||||
const latestLoginAt = rows.reduce<Date | null>((max, r) => {
|
||||
if (!r.lastLoginAt) return max;
|
||||
if (!max || r.lastLoginAt > max) return r.lastLoginAt;
|
||||
return max;
|
||||
}, null);
|
||||
|
||||
return {
|
||||
groupKey,
|
||||
unionId,
|
||||
identityCount: rows.length,
|
||||
actorTypes,
|
||||
multiRole: rows.length > 1,
|
||||
primaryPhone: phones[0] ?? null,
|
||||
latestLoginAt,
|
||||
identities: rows.map(mapBindingRow),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminWechatBindingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminWechatBindingsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
let rows = await this.fetchBindings(query);
|
||||
const shouldExpandUnion = !!(query.phone || query.openId || query.actorType);
|
||||
if (shouldExpandUnion) {
|
||||
const unionIds = [
|
||||
...new Set(rows.map((r) => r.wxUnionId).filter((id): id is string => !!id)),
|
||||
];
|
||||
if (unionIds.length > 0) {
|
||||
const expanded = (
|
||||
await Promise.all(unionIds.map((unionId) => this.fetchBindings({ unionId })))
|
||||
).flat();
|
||||
const soloRows = rows.filter((r) => !r.wxUnionId);
|
||||
rows = this.dedupeBindings([...expanded, ...soloRows]);
|
||||
}
|
||||
}
|
||||
const groups = this.groupBindings(rows);
|
||||
const summaries = [...groups.entries()]
|
||||
.map(([groupKey, groupRows]) => summarizeGroup(groupKey, groupRows))
|
||||
.sort((a, b) => {
|
||||
if (a.multiRole !== b.multiRole) return a.multiRole ? -1 : 1;
|
||||
const ta = a.latestLoginAt ? new Date(a.latestLoginAt).getTime() : 0;
|
||||
const tb = b.latestLoginAt ? new Date(b.latestLoginAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
const total = summaries.length;
|
||||
const items = summaries.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(groupKey: string) {
|
||||
const rows = await this.fetchBindings({});
|
||||
const groups = this.groupBindings(rows);
|
||||
const groupRows = groups.get(groupKey);
|
||||
if (!groupRows?.length) {
|
||||
throw new NotFoundException('微信绑定分组不存在');
|
||||
}
|
||||
return serializeBigInt(summarizeGroup(groupKey, groupRows));
|
||||
}
|
||||
|
||||
private groupBindings(rows: BindingRow[]): Map<string, BindingRow[]> {
|
||||
const groups = new Map<string, BindingRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = buildGroupKey(row);
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(row);
|
||||
groups.set(key, list);
|
||||
}
|
||||
for (const [key, list] of groups) {
|
||||
list.sort((a, b) => {
|
||||
const ta = a.lastLoginAt?.getTime() ?? 0;
|
||||
const tb = b.lastLoginAt?.getTime() ?? 0;
|
||||
return tb - ta;
|
||||
});
|
||||
groups.set(key, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private async fetchBindings(query: AdminWechatBindingsQueryDto): Promise<BindingRow[]> {
|
||||
const actorType = query.actorType as ActorType | undefined;
|
||||
const phoneFilter = query.phone?.trim();
|
||||
const unionIdFilter = query.unionId?.trim();
|
||||
const openIdFilter = query.openId?.trim();
|
||||
|
||||
const rows: BindingRow[] = [];
|
||||
|
||||
if (!actorType || actorType === 'USER') {
|
||||
const where: Prisma.UserWhereInput = {
|
||||
wxOpenId: { not: null },
|
||||
status: 1,
|
||||
mergedIntoUserId: null,
|
||||
};
|
||||
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
nickname: true,
|
||||
phoneVerifiedAt: true,
|
||||
wxOpenId: true,
|
||||
wxUnionId: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const u of users) {
|
||||
if (!u.wxOpenId) continue;
|
||||
rows.push({
|
||||
actorType: 'USER',
|
||||
actorId: u.id,
|
||||
phone: u.phone,
|
||||
name: u.nickname,
|
||||
wxOpenId: u.wxOpenId,
|
||||
wxUnionId: u.wxUnionId,
|
||||
phoneVerified: !!u.phoneVerifiedAt,
|
||||
lastLoginAt: u.updatedAt,
|
||||
status: u.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!actorType || actorType === 'STORE') {
|
||||
const where: Prisma.StoreAccountWhereInput = { wxOpenId: { not: null } };
|
||||
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||
|
||||
const accounts = await this.prisma.storeAccount.findMany({
|
||||
where,
|
||||
include: {
|
||||
bindings: {
|
||||
take: 1,
|
||||
include: { store: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const a of accounts) {
|
||||
if (!a.wxOpenId) continue;
|
||||
const firstStore = a.bindings[0]?.store;
|
||||
rows.push({
|
||||
actorType: 'STORE',
|
||||
actorId: a.id,
|
||||
phone: a.phone,
|
||||
name: a.name,
|
||||
wxOpenId: a.wxOpenId,
|
||||
wxUnionId: a.wxUnionId,
|
||||
refId: firstStore?.id,
|
||||
refLabel: firstStore?.name ?? a.name,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
status: a.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!actorType || actorType === 'PARTNER') {
|
||||
const where: Prisma.PartnerAccountWhereInput = { wxOpenId: { not: null } };
|
||||
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
wxOpenId: true,
|
||||
wxUnionId: true,
|
||||
lastLoginAt: true,
|
||||
status: true,
|
||||
isPrimary: true,
|
||||
parentAccountId: true,
|
||||
companyName: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const a of accounts) {
|
||||
if (!a.wxOpenId) continue;
|
||||
const refId = a.isPrimary === 1 ? a.id : (a.parentAccountId ?? a.id);
|
||||
rows.push({
|
||||
actorType: 'PARTNER',
|
||||
actorId: a.id,
|
||||
phone: a.phone,
|
||||
name: a.name,
|
||||
wxOpenId: a.wxOpenId,
|
||||
wxUnionId: a.wxUnionId,
|
||||
refId,
|
||||
refLabel: a.companyName,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
status: a.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!actorType || actorType === 'HQ') {
|
||||
const where: Prisma.HqAccountWhereInput = { wxOpenId: { not: null } };
|
||||
if (phoneFilter) where.phone = { contains: phoneFilter };
|
||||
if (unionIdFilter) where.wxUnionId = unionIdFilter;
|
||||
if (openIdFilter) where.wxOpenId = openIdFilter;
|
||||
|
||||
const accounts = await this.prisma.hqAccount.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
adminRole: true,
|
||||
wxOpenId: true,
|
||||
wxUnionId: true,
|
||||
lastLoginAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const a of accounts) {
|
||||
if (!a.wxOpenId) continue;
|
||||
rows.push({
|
||||
actorType: 'HQ',
|
||||
actorId: a.id,
|
||||
phone: a.phone,
|
||||
name: a.name,
|
||||
wxOpenId: a.wxOpenId,
|
||||
wxUnionId: a.wxUnionId,
|
||||
refLabel: a.adminRole,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
status: a.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private dedupeBindings(rows: BindingRow[]): BindingRow[] {
|
||||
const map = new Map<string, BindingRow>();
|
||||
for (const row of rows) {
|
||||
map.set(`${row.actorType}:${row.actorId.toString()}`, row);
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { CreateWecomBotRequest, UpdateWecomBotRequest } from '@dukang/shared-types';
|
||||
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 { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
|
||||
@Controller('admin/wecom-bots')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('wecom_bots')
|
||||
export class AdminWecomBotsController {
|
||||
constructor(
|
||||
private readonly service: AdminWecomBotsService,
|
||||
private readonly llmConfigs: AdminLlmConfigsService,
|
||||
private readonly knowledgeBases: AdminKnowledgeBasesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('name') name?: string,
|
||||
@Query('role') role?: string,
|
||||
@Query('enabled') enabled?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.list({
|
||||
name,
|
||||
role,
|
||||
enabled,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('reload')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_BOT_RELOAD,
|
||||
refType: 'WECOM_BOT',
|
||||
batch: true,
|
||||
})
|
||||
reload() {
|
||||
return this.service.reloadRuntime();
|
||||
}
|
||||
|
||||
@Get('ai-options')
|
||||
async aiOptions(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.llmConfigs.resolveActor(user.actorId);
|
||||
const [llmConfigs, knowledgeBases] = await Promise.all([
|
||||
this.llmConfigs.options(actor),
|
||||
this.knowledgeBases.options(actor),
|
||||
]);
|
||||
return { llmConfigs, knowledgeBases };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_BOT_CREATE,
|
||||
refType: 'WECOM_BOT',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() body: CreateWecomBotRequest) {
|
||||
return this.service.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_BOT_UPDATE,
|
||||
refType: 'WECOM_BOT',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() body: UpdateWecomBotRequest) {
|
||||
return this.service.update(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_BOT_DELETE,
|
||||
refType: 'WECOM_BOT',
|
||||
refIdField: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
WECOM_BOT_ROLES,
|
||||
parseWecomBotPermissions,
|
||||
resolveWecomBotPermissions,
|
||||
type CreateWecomBotRequest,
|
||||
type UpdateWecomBotRequest,
|
||||
type WecomBotDto,
|
||||
type WecomBotPermission,
|
||||
type WecomBotRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
|
||||
|
||||
function isWecomRole(v: string): v is WecomBotRole {
|
||||
return (WECOM_BOT_ROLES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
type WecomRow = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
role: string;
|
||||
botId: string;
|
||||
secret: string;
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: string;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: bigint | null;
|
||||
knowledgeBaseId: bigint | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
llmConfig?: { id: bigint; name: string } | null;
|
||||
knowledgeBase?: { id: bigint; name: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminWecomBotsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wecomAibot: WecomAibotService,
|
||||
) {}
|
||||
|
||||
async list(query: { name?: string; role?: string; enabled?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
name?: { contains: string };
|
||||
role?: string;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||
if (query.role?.trim()) where.role = query.role.trim();
|
||||
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||
where.enabled = query.enabled === 'true';
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.wecomBot.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.wecomBot.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
runtime: this.wecomAibot.getStatus(),
|
||||
});
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const row = await this.prisma.wecomBot.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('机器人不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(dto: CreateWecomBotRequest) {
|
||||
const name = dto.name?.trim();
|
||||
const botId = dto.botId?.trim();
|
||||
const secret = dto.secret?.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
if (!botId) throw new BadRequestException('请填写 BotID');
|
||||
if (!secret) throw new BadRequestException('请填写 Secret');
|
||||
if (!isWecomRole(dto.role)) throw new BadRequestException('无效角色');
|
||||
|
||||
const exists = await this.prisma.wecomBot.findUnique({ where: { botId } });
|
||||
if (exists) throw new BadRequestException('BotID 已存在');
|
||||
|
||||
const llmConfigId = await this.resolveLlmId(dto.llmConfigId);
|
||||
const knowledgeBaseId = await this.resolveKbId(dto.knowledgeBaseId);
|
||||
const permissions = resolveWecomBotPermissions(dto.role, dto.permissions);
|
||||
const row = await this.prisma.wecomBot.create({
|
||||
data: {
|
||||
name,
|
||||
role: dto.role,
|
||||
botId,
|
||||
secret,
|
||||
avatarUrl: dto.avatarUrl?.trim() || null,
|
||||
welcome: dto.welcome?.trim() || null,
|
||||
permissions: JSON.stringify(permissions),
|
||||
aiEnabled: dto.aiEnabled === true,
|
||||
llmConfigId,
|
||||
knowledgeBaseId,
|
||||
enabled: dto.enabled !== false,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.wecomAibot.reload('bot-create');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateWecomBotRequest) {
|
||||
const existing = await this.prisma.wecomBot.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('机器人不存在');
|
||||
|
||||
const role = dto.role && isWecomRole(dto.role) ? dto.role : (existing.role as WecomBotRole);
|
||||
if (dto.role && !isWecomRole(dto.role)) throw new BadRequestException('无效角色');
|
||||
|
||||
let botId = existing.botId;
|
||||
if (dto.botId !== undefined) {
|
||||
botId = dto.botId.trim();
|
||||
if (!botId) throw new BadRequestException('BotID 不能为空');
|
||||
if (botId !== existing.botId) {
|
||||
const dup = await this.prisma.wecomBot.findUnique({ where: { botId } });
|
||||
if (dup) throw new BadRequestException('BotID 已存在');
|
||||
}
|
||||
}
|
||||
|
||||
let permissionsJson = existing.permissions;
|
||||
if (dto.permissions !== undefined || dto.role !== undefined) {
|
||||
const permissions =
|
||||
dto.permissions !== undefined
|
||||
? parseWecomBotPermissions(dto.permissions)
|
||||
: resolveWecomBotPermissions(role, existing.permissions);
|
||||
const finalPerms =
|
||||
dto.permissions !== undefined
|
||||
? permissions.length
|
||||
? permissions
|
||||
: resolveWecomBotPermissions(role, null)
|
||||
: resolveWecomBotPermissions(role, existing.permissions);
|
||||
permissionsJson = JSON.stringify(finalPerms);
|
||||
}
|
||||
|
||||
const secret =
|
||||
dto.secret !== undefined && dto.secret.trim() ? dto.secret.trim() : existing.secret;
|
||||
|
||||
const llmConfigId =
|
||||
dto.llmConfigId !== undefined ? await this.resolveLlmId(dto.llmConfigId) : undefined;
|
||||
const knowledgeBaseId =
|
||||
dto.knowledgeBaseId !== undefined ? await this.resolveKbId(dto.knowledgeBaseId) : undefined;
|
||||
|
||||
const row = await this.prisma.wecomBot.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name !== undefined ? dto.name.trim() : undefined,
|
||||
role: dto.role,
|
||||
botId,
|
||||
secret,
|
||||
avatarUrl:
|
||||
dto.avatarUrl === undefined ? undefined : dto.avatarUrl?.trim() || null,
|
||||
welcome: dto.welcome === undefined ? undefined : dto.welcome?.trim() || null,
|
||||
permissions: permissionsJson,
|
||||
aiEnabled: dto.aiEnabled,
|
||||
llmConfigId,
|
||||
knowledgeBaseId,
|
||||
enabled: dto.enabled,
|
||||
sortOrder: dto.sortOrder,
|
||||
},
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.wecomAibot.reload('bot-update');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
const existing = await this.prisma.wecomBot.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('机器人不存在');
|
||||
await this.prisma.wecomBot.delete({ where: { id } });
|
||||
await this.wecomAibot.reload('bot-delete');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async reloadRuntime() {
|
||||
return this.wecomAibot.reload('manual');
|
||||
}
|
||||
|
||||
private async resolveLlmId(raw?: string | null): Promise<bigint | null> {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null || raw === '') return null;
|
||||
const id = BigInt(raw);
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('语言模型配置不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
private async resolveKbId(raw?: string | null): Promise<bigint | null> {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null || raw === '') return null;
|
||||
const id = BigInt(raw);
|
||||
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('知识库不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
private toDto(row: WecomRow): WecomBotDto {
|
||||
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
|
||||
const permissions = resolveWecomBotPermissions(role, row.permissions) as WecomBotPermission[];
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
role,
|
||||
botId: row.botId,
|
||||
secretConfigured: !!row.secret,
|
||||
avatarUrl: row.avatarUrl,
|
||||
welcome: row.welcome,
|
||||
permissions,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId?.toString() ?? null,
|
||||
llmConfigName: row.llmConfig?.name ?? null,
|
||||
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
|
||||
knowledgeBaseName: row.knowledgeBase?.name ?? null,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import {
|
||||
XiaofeixiaBatchShipmentQueryDto,
|
||||
XiaofeixiaCheckCoverageDto,
|
||||
XiaofeixiaCreateShipmentDto,
|
||||
XiaofeixiaEstimateFreightDto,
|
||||
XiaofeixiaShipmentQueryDto,
|
||||
} from './dto/admin-courier.dto';
|
||||
|
||||
/** HQ 小飞侠接口联调(仅管理端,勿对 C 端暴露) */
|
||||
@Controller('admin/courier/xiaofeixia')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminXiaofeixiaController {
|
||||
constructor(private readonly service: AdminXiaofeixiaService) {}
|
||||
|
||||
@Get('config')
|
||||
getConfig() {
|
||||
return this.service.getConfig();
|
||||
}
|
||||
|
||||
@Post('estimate-freight')
|
||||
estimateFreight(@Body() body: XiaofeixiaEstimateFreightDto) {
|
||||
return this.service.estimateFreight(body);
|
||||
}
|
||||
|
||||
@Post('check-coverage')
|
||||
checkCoverage(@Body() body: XiaofeixiaCheckCoverageDto) {
|
||||
return this.service.checkCoverage(body);
|
||||
}
|
||||
|
||||
@Post('create-shipment')
|
||||
createShipment(@Body() body: XiaofeixiaCreateShipmentDto) {
|
||||
return this.service.createShipment(body);
|
||||
}
|
||||
|
||||
@Post('cancel-shipment')
|
||||
cancelShipment(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||
return this.service.cancelShipment(body);
|
||||
}
|
||||
|
||||
@Post('get-shipment')
|
||||
getShipment(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||
return this.service.getShipment(body);
|
||||
}
|
||||
|
||||
@Post('batch-get-shipments')
|
||||
batchGetShipments(@Body() body: XiaofeixiaBatchShipmentQueryDto) {
|
||||
return this.service.batchGetShipments(body);
|
||||
}
|
||||
|
||||
@Post('get-track')
|
||||
getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||
return this.service.getTrack(body);
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { CourierConfigService } from '../../integrations/courier/courier.config';
|
||||
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
|
||||
import { CourierApiError } from '../../integrations/courier/courier.error';
|
||||
import { CourierService } from '../../integrations/courier/courier.service';
|
||||
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||
import type {
|
||||
BatchShipmentQuery,
|
||||
CreateShipmentInput,
|
||||
ShipmentQuery,
|
||||
} from '../../integrations/courier/courier.types';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import type {
|
||||
XiaofeixiaBatchShipmentQueryDto,
|
||||
XiaofeixiaCheckCoverageDto,
|
||||
XiaofeixiaCreateShipmentDto,
|
||||
XiaofeixiaEstimateFreightDto,
|
||||
XiaofeixiaShipmentQueryDto,
|
||||
} from './dto/admin-courier.dto';
|
||||
|
||||
function maskSecret(value: string, visible = 4) {
|
||||
if (!value) return '';
|
||||
if (value.length <= visible) return '*'.repeat(value.length);
|
||||
return `${value.slice(0, visible)}${'*'.repeat(Math.min(8, value.length - visible))}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminXiaofeixiaService {
|
||||
constructor(
|
||||
private readonly courier: CourierService,
|
||||
private readonly courierConfig: CourierConfigService,
|
||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||||
) {}
|
||||
|
||||
async getConfig() {
|
||||
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
||||
const envCfg = this.courierConfig.load().xiaofeixia;
|
||||
const xfx = fromDb ?? envCfg;
|
||||
return {
|
||||
provider: this.courierConfig.load().provider,
|
||||
activeProvider: this.courier.activeProvider,
|
||||
source: fromDb ? 'fulfillment_provider' : 'env',
|
||||
apiUrl: xfx.apiUrl,
|
||||
appId: xfx.appId ?? null,
|
||||
mchId: xfx.mchId || null,
|
||||
mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null,
|
||||
hasApiKey: Boolean(xfx.apiKey),
|
||||
signType: xfx.signType,
|
||||
ready: Boolean(xfx.mchId && xfx.apiKey && xfx.apiUrl),
|
||||
hint: fromDb
|
||||
? '凭证来自仓配管理中启用的小飞侠承运商'
|
||||
: '未在仓配管理配置,回退到环境变量(请迁移至仓配管理)',
|
||||
};
|
||||
}
|
||||
|
||||
async estimateFreight(dto: XiaofeixiaEstimateFreightDto) {
|
||||
const options = await this.callOptions();
|
||||
return this.wrap(() => this.courier.estimateFreight(dto.weight, options));
|
||||
}
|
||||
|
||||
async checkCoverage(dto: XiaofeixiaCheckCoverageDto) {
|
||||
const options = await this.callOptions();
|
||||
return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress, options));
|
||||
}
|
||||
|
||||
async createShipment(dto: XiaofeixiaCreateShipmentDto, xfxOverride?: XiaofeixiaConfig) {
|
||||
const input = this.mapCreateInput(dto);
|
||||
const options = xfxOverride
|
||||
? { xiaofeixia: xfxOverride }
|
||||
: await this.callOptions();
|
||||
return this.wrap(() => this.courier.createShipment(input, options));
|
||||
}
|
||||
|
||||
async cancelShipment(dto: XiaofeixiaShipmentQueryDto) {
|
||||
const query = this.mapShipmentQuery(dto);
|
||||
const options = await this.callOptions();
|
||||
return this.wrap(async () => {
|
||||
await this.courier.cancelShipment(query, options);
|
||||
return { cancelled: true };
|
||||
});
|
||||
}
|
||||
|
||||
async getShipment(dto: XiaofeixiaShipmentQueryDto) {
|
||||
const query = this.mapShipmentQuery(dto);
|
||||
const options = await this.callOptions();
|
||||
return this.wrap(() => this.courier.getShipment(query, options));
|
||||
}
|
||||
|
||||
async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) {
|
||||
const query: BatchShipmentQuery = {
|
||||
trackingNumbers: dto.trackingNumbers?.filter(Boolean),
|
||||
outNumbers: dto.outNumbers?.filter(Boolean),
|
||||
};
|
||||
const options = await this.callOptions();
|
||||
return this.wrap(() => this.courier.batchGetShipments(query, options));
|
||||
}
|
||||
|
||||
async getTrack(dto: XiaofeixiaShipmentQueryDto) {
|
||||
const query = this.mapShipmentQuery(dto);
|
||||
const options = await this.callOptions();
|
||||
return this.wrap(() => this.courier.getTrack(query, options));
|
||||
}
|
||||
|
||||
private async callOptions() {
|
||||
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
||||
return fromDb ? { xiaofeixia: fromDb } : undefined;
|
||||
}
|
||||
|
||||
private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery {
|
||||
if (!dto.trackingNumber && !dto.outNumber) {
|
||||
throw new BadRequestException('运单号与商家单号至少填一个');
|
||||
}
|
||||
return {
|
||||
trackingNumber: dto.trackingNumber,
|
||||
outNumber: dto.outNumber,
|
||||
};
|
||||
}
|
||||
|
||||
private mapCreateInput(dto: XiaofeixiaCreateShipmentDto): CreateShipmentInput {
|
||||
const coord = (lng?: number, lat?: number) =>
|
||||
lng != null && lat != null ? { lng, lat } : undefined;
|
||||
|
||||
return {
|
||||
outNumber: dto.outNumber,
|
||||
customerId: dto.customerId,
|
||||
from: {
|
||||
name: dto.fromName,
|
||||
mobile: dto.fromMobile,
|
||||
address: dto.fromAddress,
|
||||
addressDetail: dto.fromAddressDetail,
|
||||
coordinate: coord(dto.fromLng, dto.fromLat),
|
||||
},
|
||||
to: {
|
||||
name: dto.toName,
|
||||
mobile: dto.toMobile,
|
||||
address: dto.toAddress,
|
||||
addressDetail: dto.toAddressDetail,
|
||||
coordinate: coord(dto.toLng, dto.toLat),
|
||||
},
|
||||
goodsName: dto.goodsName,
|
||||
goodsNum: dto.goodsNum,
|
||||
weight: dto.weight,
|
||||
insuredSumPrice: dto.insuredSumPrice,
|
||||
collectionPrice: dto.collectionPrice,
|
||||
payMode: dto.payMode as CourierPayMode,
|
||||
remark: dto.remark,
|
||||
};
|
||||
}
|
||||
|
||||
private async wrap<T>(fn: () => Promise<T>) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const data = await fn();
|
||||
return {
|
||||
ok: true,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
data,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof CourierApiError) {
|
||||
return {
|
||||
ok: false,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
provider: err.providerCode,
|
||||
raw: err.raw,
|
||||
};
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class XiaofeixiaEstimateFreightDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export class XiaofeixiaCheckCoverageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
toAddress: string;
|
||||
}
|
||||
|
||||
export class XiaofeixiaShipmentQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trackingNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
outNumber?: string;
|
||||
}
|
||||
|
||||
export class XiaofeixiaBatchShipmentQueryDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
trackingNumbers?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
outNumbers?: string[];
|
||||
}
|
||||
|
||||
export class XiaofeixiaCreateShipmentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
outNumber: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fromName: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fromMobile: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fromAddress: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fromAddressDetail: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
fromLng?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
fromLat?: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
toName: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
toMobile: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
toAddress: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
toAddressDetail: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
toLng?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
toLat?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodsName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
goodsNum?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
weight?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
insuredSumPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
collectionPrice?: number;
|
||||
|
||||
@IsIn(['1', '2'])
|
||||
payMode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,526 +0,0 @@
|
||||
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 AdminUsersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['0', '1'])
|
||||
phoneVerified?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsIn([0, 1])
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['NORMAL', 'PROXY'])
|
||||
orderType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverPhone?: string;
|
||||
|
||||
/** 仅看大单拦截待总部确认:true / 1 */
|
||||
@IsOptional()
|
||||
fulfillmentHold?: string | boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdTo?: string;
|
||||
}
|
||||
|
||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||
export class AdminDashboardAnalyticsQueryDto {
|
||||
/** YYYY-MM-DD,默认近 30 天 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
/** 开城城市 id;`none` = 用户未选城 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
/** 推广码 id;`none` = 无归因 / 订单无推广码 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
|
||||
/** 城市合伙人(主账号)id */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerAccountId?: string;
|
||||
}
|
||||
|
||||
export class AdminStoresQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
auditStatus?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class AdminPartnersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
managerType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
isPrimary?: string;
|
||||
}
|
||||
|
||||
export class AdminBenefitCouponsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
couponNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class AdminBenefitLedgersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
couponId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redeemNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
/** SCAN | PHONE */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redeemNo?: string;
|
||||
}
|
||||
|
||||
export class AdminDeliveriesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
provider?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trackingNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderNo?: string;
|
||||
}
|
||||
|
||||
export class AdminHqAccountsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adminRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class AdminCitiesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
export class AdminProductsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aromaType?: string;
|
||||
}
|
||||
|
||||
export class AdminProductDetailTemplatesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aromaType?: string;
|
||||
}
|
||||
|
||||
export class AdminUserLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeAccountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class AdminPartnerLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerAccountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class AdminHqLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
hqAccountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class AdminOssLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scene?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['SUCCESS', 'FAILED', 'PENDING'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bizType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientApp?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
export class AdminPromoCodesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class AdminWechatBindingsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['USER', 'STORE', 'PARTNER', 'HQ'])
|
||||
actorType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
unionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
openId?: string;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { PaginationQueryDto } from './admin-query.dto';
|
||||
|
||||
export class AdminRedeemPendingQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING', 'COMPLETED', 'REJECTED'])
|
||||
status?: 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pendingNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redeemToken?: string;
|
||||
}
|
||||
|
||||
export class AdminRedeemPendingRejectDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
reason: string;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class HqProxyOrderPreviewDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverCity?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverDistrict?: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderCreateDto {
|
||||
@IsString()
|
||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||
phone: string;
|
||||
|
||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsBoolean()
|
||||
autoReceive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
receiverName?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
province?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
city?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
district?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
addressDetail?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderPayDto {
|
||||
@IsOptional()
|
||||
@IsIn(['NATIVE', 'JSAPI'])
|
||||
payMethod?: 'NATIVE' | 'JSAPI';
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import { StoreModule } from '../store/store.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminProxyOrdersController } from './admin-proxy-orders.controller';
|
||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||
import { AdminStoresService } from './admin-stores.service';
|
||||
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
||||
import { AdminPartnersService } from './admin-partners.service';
|
||||
import { AdminCitiesController } from './admin-cities.controller';
|
||||
import { AdminCityWarehousesController, AdminCityWarehouseMutationsController } from './admin-city-warehouses.controller';
|
||||
import { AdminCitiesService } from './admin-cities.service';
|
||||
import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller';
|
||||
import { AdminBenefitService } from './admin-benefit.service';
|
||||
import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin-redeem.controller';
|
||||
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
|
||||
import { AdminStoreRatingsController } from './admin-store-ratings.controller';
|
||||
import { AdminStoreRatingsService } from './admin-store-ratings.service';
|
||||
import { AdminHqAccountsController } from './admin-hq-accounts.controller';
|
||||
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { AdminUserLogsController } from './admin-user-logs.controller';
|
||||
import { AdminUserLogsService } from './admin-user-logs.service';
|
||||
import { AdminStoreLogsController } from './admin-store-logs.controller';
|
||||
import { AdminStoreLogsService } from './admin-store-logs.service';
|
||||
import { AdminPartnerLogsController } from './admin-partner-logs.controller';
|
||||
import { AdminPartnerLogsService } from './admin-partner-logs.service';
|
||||
import { AdminHqLogsController } from './admin-hq-logs.controller';
|
||||
import { AdminHqLogsService } from './admin-hq-logs.service';
|
||||
import { AdminOssLogsController } from './admin-oss-logs.controller';
|
||||
import { AdminOssLogsService } from './admin-oss-logs.service';
|
||||
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { AdminSupportTicketsController } from './admin-support-tickets.controller';
|
||||
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { WecomModule } from '../../integrations/wecom/wecom.module';
|
||||
import { LlmModule } from '../../integrations/llm/llm.module';
|
||||
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';
|
||||
import { AdminRedeemPendingController } from './admin-redeem-pending.controller';
|
||||
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
|
||||
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
||||
import { AdminHqPermissionsController } from './admin-hq-permissions.controller';
|
||||
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
import { AdminDeployController } from './admin-deploy.controller';
|
||||
import { AdminDeployService } from './admin-deploy.service';
|
||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
import { AdminWecomBotsController } from './admin-wecom-bots.controller';
|
||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||
import { AdminLlmConfigsController } from './admin-llm-configs.controller';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
import { AdminKnowledgeBasesController } from './admin-knowledge-bases.controller';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
import { AdminDomainEventsController } from './admin-domain-events.controller';
|
||||
import { AdminDomainEventsService } from './admin-domain-events.service';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
AdminUsersController,
|
||||
AdminOrdersController,
|
||||
AdminProxyOrdersController,
|
||||
AdminStoresController,
|
||||
AdminStoreAccountsController,
|
||||
AdminStoreMediaController,
|
||||
AdminStoreCategoriesController,
|
||||
AdminPartnersController,
|
||||
AdminPartnerAccountsController,
|
||||
AdminCitiesController,
|
||||
AdminCityWarehousesController,
|
||||
AdminCityWarehouseMutationsController,
|
||||
AdminBenefitCouponsController,
|
||||
AdminBenefitLedgersController,
|
||||
AdminRedeemRecordsController,
|
||||
AdminStoreRatingsController,
|
||||
AdminDeliveriesController,
|
||||
AdminHqAccountsController,
|
||||
AdminProductsController,
|
||||
AdminUserLogsController,
|
||||
AdminStoreLogsController,
|
||||
AdminPartnerLogsController,
|
||||
AdminHqLogsController,
|
||||
AdminDomainEventsController,
|
||||
AdminOssLogsController,
|
||||
AdminTicketsController,
|
||||
AdminSupportTicketsController,
|
||||
AdminInvoicesController,
|
||||
AdminXiaofeixiaController,
|
||||
AdminProductDetailTemplatesController,
|
||||
AdminRedeemDebugController,
|
||||
AdminRedeemPendingController,
|
||||
AdminWechatBindingsController,
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
AdminWecomBotsController,
|
||||
AdminLlmConfigsController,
|
||||
AdminKnowledgeBasesController,
|
||||
AdminFulfillmentProvidersController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
AdminUsersService,
|
||||
AdminOrdersService,
|
||||
AdminStoresService,
|
||||
AdminPartnersService,
|
||||
AdminCitiesService,
|
||||
AdminBenefitService,
|
||||
AdminRedeemService,
|
||||
AdminStoreRatingsService,
|
||||
AdminDeliveriesService,
|
||||
AdminHqAccountsService,
|
||||
AdminProductsService,
|
||||
AdminUserLogsService,
|
||||
AdminStoreLogsService,
|
||||
AdminPartnerLogsService,
|
||||
AdminHqLogsService,
|
||||
AdminDomainEventsService,
|
||||
AdminOssLogsService,
|
||||
AdminTicketsService,
|
||||
AdminXiaofeixiaService,
|
||||
AdminProductDetailTemplatesService,
|
||||
AdminRedeemDebugService,
|
||||
AdminWechatBindingsService,
|
||||
AdminHqPermissionsService,
|
||||
AdminDeployService,
|
||||
AdminWecomBotsService,
|
||||
AdminLlmConfigsService,
|
||||
AdminKnowledgeBasesService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
exports: [CityScopeModule],
|
||||
})
|
||||
export class OpsModule {}
|
||||
Reference in New Issue
Block a user