feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
@@ -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 返回非 JSONHTTP ${res.status}`);
}
if (res.status === 403) {
throw new BadRequestException(data.message || '部署 webhook 鉴权失败');
}
if (!res.ok && res.status !== 202) {
throw new ServiceUnavailableException(data.message || `部署 webhook 失败(HTTP ${res.status}`);
}
return {
accepted: true,
started: data.started !== false && !data.skipped,
message: data.message || (data.started === false ? 'deploy debounced' : 'deploy started'),
};
}
}
@@ -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('银行卡号须为 1619 位数字');
}
}
}
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 {}