发布商品,商品图片使用oss服务器地址

This commit is contained in:
2026-07-06 08:53:03 +08:00
parent a0466f023e
commit 5ba69eb935
60 changed files with 2776 additions and 157 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"lint": "echo ok",
"lint": "eslint src",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { IntegrationsModule } from '../integrations/integrations.module';
import { TradeModule } from '../modules/trade/trade.module';
import { PrismaModule } from '../common/prisma/prisma.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
@Module({
imports: [IntegrationsModule, TradeModule],
controllers: [WechatPayCallbackController],
imports: [IntegrationsModule, TradeModule, PrismaModule],
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
})
export class CallbacksModule {}
@@ -0,0 +1,36 @@
import { Body, Controller, Post } from '@nestjs/common';
import { TradeService } from '../modules/trade/trade.service';
import { CourierService } from '../integrations/courier/courier.service';
import { PrismaService } from '../common/prisma/prisma.module';
@Controller('callbacks/delivery')
export class DeliveryCallbackController {
constructor(
private readonly tradeService: TradeService,
private readonly courier: CourierService,
private readonly prisma: PrismaService,
) {}
@Post('track')
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
if (!body.orderId && !body.orderNo) {
return this.courier.buildTrackCallbackResponse(false);
}
const order = body.orderId
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
if (!order) return this.courier.buildTrackCallbackResponse(false);
const statusMap: Record<string, string> = {
SHIPPED: 'SHIPPING',
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
DELIVERED: 'COMPLETED',
COMPLETED: 'COMPLETED',
};
const target = statusMap[body.status ?? ''] ?? body.status;
if (target && target !== order.status) {
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
}
return this.courier.buildTrackCallbackResponse(true);
}
}
@@ -0,0 +1,44 @@
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { PrismaService } from '../common/prisma/prisma.module';
@Controller('callbacks/wechat')
export class WechatRefundCallbackController {
constructor(private readonly prisma: PrismaService) {}
@Post('refund')
async refundNotify(
@Req() req: Request,
@Headers() _headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
try {
const body = typeof req.body === 'object' ? req.body : {};
const outRefundNo = String((body as Record<string, unknown>).out_refund_no ?? '');
const refundId = String((body as Record<string, unknown>).refund_id ?? outRefundNo);
const existing = await this.prisma.logThirdParty.findFirst({
where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' },
});
if (existing) {
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
}
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_REFUND',
scene: 'ORDER_REFUND_CALLBACK',
refType: 'TICKET',
refId: BigInt(0),
externalNo: refundId,
status: 'SUCCESS',
},
});
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
const message = err instanceof Error ? err.message : '处理失败';
return res.status(500).json({ code: 'FAIL', message });
}
}
}
@@ -26,4 +26,8 @@ export class RedisService {
async del(key: string) {
await this.redis.del(key);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
}
@@ -1,9 +1,12 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
export type CouponAllocation = { couponId: string; amount: number };
@Injectable()
export class BenefitService {
constructor(private readonly prisma: PrismaService) {}
@@ -85,4 +88,73 @@ export class BenefitService {
});
return serializeBigInt({ coupon, ledgers });
}
/** 核销扣减券余额(乐观锁),由 redeem 模块调用 */
async deductCoupons(
tx: Prisma.TransactionClient,
allocations: CouponAllocation[],
refType: 'STORE',
refId: bigint,
) {
for (const alloc of allocations) {
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
where: { id: BigInt(alloc.couponId) },
});
const allocAmount = alloc.amount;
const updated = await tx.benefitCoupon.updateMany({
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
data: {
usedAmount: { increment: allocAmount },
balance: { decrement: allocAmount },
version: { increment: 1 },
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
},
});
if (updated.count === 0) throw new Error('BENEFIT_DEDUCT_CONFLICT');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
amount: -allocAmount,
balanceAfter: newBalance,
refType,
refId,
}),
});
}
}
/** 退款作废权益 */
async voidCouponsOnRefund(orderId: bigint) {
const coupons = await this.prisma.benefitCoupon.findMany({
where: { orderId, status: { in: ['ACTIVE', 'USED_UP'] } },
});
for (const coupon of coupons) {
const balance = Number(coupon.balance);
if (balance <= 0 && coupon.status === 'USED_UP') continue;
await this.prisma.$transaction(async (tx) => {
await tx.benefitCoupon.update({
where: { id: coupon.id },
data: { status: 'VOID', balance: 0 },
});
if (balance > 0) {
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REFUND_VOID',
amount: -balance,
balanceAfter: 0,
refType: 'ORDER',
refId: orderId,
remark: '退款作废权益',
}),
});
}
});
}
}
}
@@ -11,8 +11,8 @@ export class CatalogController {
}
@Get('products')
products(@Query('aromaType') aromaType?: string) {
return this.catalogService.listProducts(aromaType);
products(@Query('aromaType') aromaType?: string, @Query('cityCode') cityCode?: string) {
return this.catalogService.listProducts(aromaType, cityCode);
}
@Get('products/:id')
@@ -0,0 +1,62 @@
import type { CommonProductItem, CommonResource } from '@prisma/client';
export type ProductMediaDto = {
mainImageUrl: string | null;
carouselUrls: string[];
detailImageUrls: string[];
};
type ProductWithCover = CommonProductItem & {
coverResource?: { url: string } | null;
};
function urlsFromResources(resources: CommonResource[], bizType: 'CAROUSEL' | 'DETAIL') {
return resources
.filter((r) => r.bizType === bizType && r.url)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((r) => r.url);
}
export function mapProductMedia(
product: ProductWithCover,
extraResources: CommonResource[] = [],
): ProductMediaDto {
const mainImageUrl = product.coverResource?.url ?? null;
const carouselFromDb = urlsFromResources(extraResources, 'CAROUSEL');
const detailFromDb = urlsFromResources(extraResources, 'DETAIL');
const detailFromJson = parseDetailContentImages(product.detailContent);
const carouselUrls =
carouselFromDb.length > 0
? carouselFromDb
: mainImageUrl
? [mainImageUrl]
: [];
const detailImageUrls =
detailFromDb.length > 0
? detailFromDb
: detailFromJson;
return { mainImageUrl, carouselUrls, detailImageUrls };
}
function parseDetailContentImages(detailContent: unknown): string[] {
if (!detailContent || typeof detailContent !== 'object') return [];
const record = detailContent as Record<string, unknown>;
const images = record.images ?? record.detailImages ?? record.detailImageUrls;
if (!Array.isArray(images)) return [];
return images.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
export function groupResourcesByProductId(resources: CommonResource[]) {
const map = new Map<string, CommonResource[]>();
for (const resource of resources) {
const key = resource.ownerId.toString();
const list = map.get(key) ?? [];
list.push(resource);
map.set(key, list);
}
return map;
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
@Injectable()
export class CatalogService {
@@ -10,24 +11,50 @@ export class CatalogService {
const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: { partner: { select: { companyName: true } } },
orderBy: { name: 'asc' },
});
return serializeBigInt(cities);
}
async listProducts(aromaType?: string) {
async listProducts(aromaType?: string, cityCode?: string) {
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({
where: { code: cityCode, status: 'ACTIVE' },
});
if (!city) throw new BadRequestException('该城市暂未开城');
}
const products = await this.prisma.commonProductItem.findMany({
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
orderBy: { sortOrder: 'asc' },
include: { coverResource: true },
});
const productIds = products.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(
products.map((p) => ({
...p,
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
mainImageUrl: p.coverResource?.url ?? null,
})),
products.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
return {
...p,
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
...media,
};
}),
);
}
@@ -37,11 +64,23 @@ export class CatalogService {
include: { coverResource: true },
});
if (!product) return null;
const resources = await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: id,
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
});
const media = mapProductMedia(product, resources);
return serializeBigInt({
...product,
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
mainImageUrl: product.coverResource?.url ?? null,
...media,
});
}
}
@@ -316,7 +316,7 @@ export class AuthService {
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let user = await this.prisma.user.findFirst({
const user = await this.prisma.user.findFirst({
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
include: { avatar: true },
});
@@ -20,6 +20,9 @@ export class AdminDashboardService {
partnersTotal,
redeemToday,
deliveriesTotal,
pendingPayouts,
pendingBills,
openTickets,
] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
this.prisma.user.count({
@@ -38,6 +41,9 @@ export class AdminDashboardService {
this.prisma.partner.count(),
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: { in: ['DRAFT', 'CONFIRMED'] } } }),
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
]);
return {
@@ -50,6 +56,9 @@ export class AdminDashboardService {
partnersTotal,
redeemToday,
deliveriesTotal,
pendingPayouts,
pendingBills,
openTickets,
ordersByStatus: ordersByStatus.map((row) => ({
status: row.status,
count: row._count.status,
@@ -45,6 +45,11 @@ export class AdminStoresController {
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
return this.service.updateStoreStatus(BigInt(id), dto);
}
@Put(':id/audit')
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
return this.service.auditStore(BigInt(id), body);
}
}
@Controller('admin/store-accounts')
@@ -93,6 +93,27 @@ export class AdminStoresService {
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('门店不存在');
const status = dto.approved ? 'OPEN' : 'PAUSED';
const updated = await this.prisma.store.update({
where: { id },
data: { status },
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: id,
actorType: 'HQ',
status: dto.approved ? 'APPROVED' : 'REJECTED',
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
},
});
return serializeBigInt(updated);
}
async updateStore(id: bigint, dto: UpdateStoreDto) {
const store = await this.prisma.store.update({
where: { id },
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminTicketsService } from './admin-tickets.service';
import { TicketListQueryDto } from '../common/dto/common-query.dto';
@Controller('admin/tickets')
@UseGuards(HqAuthGuard)
export class AdminTicketsController {
constructor(private readonly service: AdminTicketsService) {}
@Get()
list(@Query() query: TicketListQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post(':id/approve')
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
return this.service.approve(BigInt(id), body.remark);
}
@Post(':id/reject')
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
return this.service.reject(BigInt(id), body.remark);
}
}
@@ -0,0 +1,107 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { BenefitService } from '../benefit/benefit.service';
import { TradeService } from '../trade/trade.service';
import { TicketService } from '../common/ticket.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
@Injectable()
export class AdminTicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
private readonly tradeService: TradeService,
private readonly benefitService: BenefitService,
) {}
list(query: TicketListQueryDto) {
return this.ticketService.list(query);
}
detail(id: bigint) {
return this.ticketService.detail(id);
}
async approve(id: bigint, remark?: string) {
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.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
const orderId = ticket.refId;
await this.prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
});
await this.benefitService.voidCouponsOnRefund(orderId);
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_REFUND',
scene: 'ORDER_REFUND',
refType: 'ORDER',
refId: orderId,
status: 'SUCCESS',
amount: 0,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'ORDER_STATUS',
refType: 'ORDER',
refId: orderId,
actorType: 'HQ',
status: 'REFUNDED',
remark: remark ?? '退款工单审批通过',
},
});
}
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
await this.tradeService.applyStatusTransition(
ticket.refId,
'PENDING_SHIP',
'PENDING_SHIP',
'HQ',
);
}
return this.ticketService.updateStatus(id, {
status: 'RESOLVED',
remark: remark ?? '审批通过',
});
}
reject(id: bigint, remark?: string) {
return this.ticketService.updateStatus(id, {
status: 'REJECTED',
remark: remark ?? '审批驳回',
});
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
select: { id: true },
});
const orderIds = orders.map((o) => o.id);
const tickets = await this.prisma.commonTicket.findMany({
where: {
ticketType: 'RESHIPMENT',
refType: 'ORDER',
refId: { in: orderIds },
},
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(tickets);
}
}
@@ -21,10 +21,14 @@ 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 { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
@Module({
imports: [IamModule, TradeModule],
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -41,6 +45,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
AdminTicketsController,
],
providers: [
AdminDashboardService,
@@ -54,6 +59,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
AdminTicketsService,
SuperAdminGuard,
],
})
@@ -29,6 +29,11 @@ export class UserRedeemController {
export class ShopRedeemController {
constructor(private readonly redeemService: RedeemService) {}
@Post('preview')
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
return this.redeemService.previewRedeem(user.actorId, body.token);
}
@Post('confirm')
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
return this.redeemService.confirmRedeem(user.actorId, body);
@@ -1,11 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module';
import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({
imports: [IamModule, forwardRef(() => SettlementModule)],
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService],
exports: [RedeemService],
@@ -15,7 +15,15 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { SettlementService } from '../settlement/settlement.service';
import { buildBenefitLedgerEvent } from '../../common/event/event.helpers';
import { BenefitService } from '../benefit/benefit.service';
type TokenPayload = {
userId: string;
couponId?: string;
amount: number;
storeId?: string | null;
allocations?: Array<{ couponId: string; amount: number }>;
};
@Injectable()
export class RedeemService {
@@ -23,6 +31,7 @@ export class RedeemService {
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly settlementService: SettlementService,
private readonly benefitService: BenefitService,
) {}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
@@ -42,6 +51,7 @@ export class RedeemService {
where: { userId, status: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
});
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
const result = allocateBenefitCoupons(
coupons.map((c) => ({
id: c.id.toString(),
@@ -51,6 +61,8 @@ export class RedeemService {
body.amount,
);
if (!result.ok) throw new BadRequestException(result.message);
const check = validateRedeemAmount(totalBalance, body.amount);
if (!check.ok) throw new BadRequestException(check.message);
allocations = result.allocations;
}
@@ -70,7 +82,7 @@ export class RedeemService {
REDEEM_TOKEN_TTL_SECONDS,
);
return { token, expireAt, amount: body.amount };
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
}
async getToken(token: string) {
@@ -79,6 +91,40 @@ export class RedeemService {
return cached;
}
async previewRedeem(storeAccountId: bigint, token: string) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
const user = await this.prisma.user.findUnique({
where: { id: BigInt(cached.userId) },
select: { id: true, userNo: true, phone: true, nickname: true },
});
const ttl = await this.redis.ttl(`redeem:token:${token}`);
return serializeBigInt({
token,
amount: cached.amount,
user,
boundStoreId: cached.storeId,
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
expireInSeconds: ttl > 0 ? ttl : 0,
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
});
}
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
@@ -88,19 +134,16 @@ export class RedeemService {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<{
userId: string;
couponId?: string;
amount: number;
allocations?: Array<{ couponId: string; amount: number }>;
}>(`redeem:token:${body.token}`);
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
const allocations =
cached.allocations ??
(cached.couponId
? [{ couponId: cached.couponId, amount: cached.amount }]
: []);
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
@@ -126,50 +169,30 @@ export class RedeemService {
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const record = await this.prisma.$transaction(async (tx) => {
for (const alloc of allocations) {
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
where: { id: BigInt(alloc.couponId) },
});
const allocAmount = alloc.amount;
const updated = await tx.benefitCoupon.updateMany({
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
let record;
try {
record = await this.prisma.$transaction(async (tx) => {
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
const redeemRecord = await tx.redeemRecord.create({
data: {
usedAmount: { increment: allocAmount },
balance: { decrement: allocAmount },
version: { increment: 1 },
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
redeemNo: generateRedeemNo(),
userId: BigInt(cached.userId),
couponId: BigInt(allocations[0].couponId),
storeId: account.storeId,
amount,
settleAmount,
},
});
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
amount: -allocAmount,
balanceAfter: newBalance,
refType: 'STORE',
refId: account.storeId,
}),
});
}
const redeemRecord = await tx.redeemRecord.create({
data: {
redeemNo: generateRedeemNo(),
userId: BigInt(cached.userId),
couponId: BigInt(allocations[0].couponId),
storeId: account.storeId,
amount,
settleAmount,
},
return redeemRecord;
});
return redeemRecord;
});
} catch (e) {
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
throw new BadRequestException('核销失败,请重试');
}
throw e;
}
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
await this.redis.del(`redeem:token:${body.token}`);
@@ -187,6 +210,7 @@ export class RedeemService {
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { payout: true },
}),
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
]);
@@ -1,7 +1,8 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PrismaService } from '../../common/prisma/prisma.module';
@Controller('partner/settlement')
@@ -15,6 +16,101 @@ export class SettlementController {
}
}
@Controller('shop/payouts')
@UseGuards(JwtAuthGuard)
export class ShopPayoutController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
}
}
@Controller('admin/store-payouts')
@UseGuards(HqAuthGuard)
export class AdminStorePayoutController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminStorePayouts({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
storeId: query.storeId,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminStorePayout(BigInt(id));
}
@Post(':id/confirm')
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
return this.settlementService.confirmStorePayout(BigInt(id), body);
}
@Post('batch-confirm')
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
}
@Post('scan-due')
scanDue() {
return this.settlementService.scanDueStorePayouts();
}
}
@Controller('admin/partner-bills')
@UseGuards(HqAuthGuard)
export class AdminPartnerBillController {
constructor(private readonly settlementService: SettlementService) {}
@Post('generate')
generate(@Body() body: { partnerId: string; year: number; month: number }) {
return this.settlementService.generatePartnerBill(body);
}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminPartnerBills({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
partnerId: query.partnerId,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.settlementService.exportPartnerBills({
partnerId: query.partnerId,
status: query.status,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminPartnerBill(BigInt(id));
}
@Post(':id/confirm')
confirm(@Param('id') id: string) {
return this.settlementService.confirmPartnerBill(BigInt(id));
}
@Post(':id/mark-paid')
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
}
}
@Controller('partner/me')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
@@ -1,11 +1,23 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { SettlementService } from './settlement.service';
import { PartnerMeController, SettlementController } from './settlement.controller';
import {
AdminPartnerBillController,
AdminStorePayoutController,
PartnerMeController,
SettlementController,
ShopPayoutController,
} from './settlement.controller';
@Module({
imports: [IamModule],
controllers: [SettlementController, PartnerMeController],
controllers: [
SettlementController,
PartnerMeController,
ShopPayoutController,
AdminStorePayoutController,
AdminPartnerBillController,
],
providers: [SettlementService],
exports: [SettlementService],
})
@@ -1,7 +1,12 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
function generateBillNo() {
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
@Injectable()
export class SettlementService {
constructor(private readonly prisma: PrismaService) {}
@@ -29,6 +34,106 @@ export class SettlementService {
return serializeBigInt(payout);
}
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
});
const where = { storeId: account.storeId };
const [items, total] = await Promise.all([
this.prisma.storePayout.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { redeemRecord: { select: { redeemNo: true, amount: true } } },
}),
this.prisma.storePayout.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async listAdminStorePayouts(query: {
page?: number;
pageSize?: number;
status?: string;
storeId?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StorePayoutWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
if (query.storeId) where.storeId = BigInt(query.storeId);
const [items, total] = await Promise.all([
this.prisma.storePayout.findMany({
where,
orderBy: { expectedPayAt: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true } },
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
},
}),
this.prisma.storePayout.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async getAdminStorePayout(id: bigint) {
const payout = await this.prisma.storePayout.findUnique({
where: { id },
include: {
store: true,
redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } },
},
});
if (!payout) throw new NotFoundException('打款记录不存在');
return serializeBigInt(payout);
}
async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) {
const payout = await this.prisma.storePayout.findUnique({ where: { id } });
if (!payout) throw new NotFoundException('打款记录不存在');
if (payout.status === 'PAID') throw new BadRequestException('已打款');
const updated = await this.prisma.storePayout.update({
where: { id },
data: {
status: 'PAID',
paidAt: new Date(),
batchNo: dto.batchNo ?? payout.batchNo,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'HQ_OPERATION',
refType: 'STORE_PAYOUT',
refId: id,
actorType: 'HQ',
status: 'PAID',
param1: dto.paymentRef ?? '',
remark: dto.remark ?? '门店 T+1 打款确认',
},
});
return serializeBigInt(updated);
}
async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo });
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async listPartnerBills(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
@@ -39,4 +144,203 @@ export class SettlementService {
});
return serializeBigInt(bills);
}
async listAdminPartnerBills(query: {
page?: number;
pageSize?: number;
status?: string;
partnerId?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerBill.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { partner: { select: { companyName: true } } },
}),
this.prisma.partnerBill.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async getAdminPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({
where: { id },
include: { partner: true },
});
if (!bill) throw new NotFoundException('账单不存在');
return serializeBigInt(bill);
}
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
const partnerId = BigInt(body.partnerId);
const periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.partnerBill.findFirst({
where: {
partnerId,
periodStart,
status: { not: 'DRAFT' },
},
});
if (existing && existing.status !== 'DRAFT') {
throw new BadRequestException('该月账单已确认,不可重复生成');
}
const cities = await this.prisma.commonCity.findMany({
where: { partnerId },
include: { commissionRule: true },
});
const cityIds = cities.map((c) => c.id);
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
? Number(cities[0].commissionRule.orderCommissionRate)
: 0.05;
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
? Number(cities[0].commissionRule.redeemCommissionRate)
: 0.03;
const orders = await this.prisma.order.findMany({
where: {
cityId: { in: cityIds },
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
},
});
const orderCommission = orders.reduce(
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
0,
);
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
const storeIds = stores.map((s) => s.id);
const redeems = await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
},
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
0,
);
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
const draft = await this.prisma.partnerBill.findFirst({
where: { partnerId, periodStart, status: 'DRAFT' },
});
const bill = draft
? await this.prisma.partnerBill.update({
where: { id: draft.id },
data: { orderCommission, redeemCommission, totalAmount, periodEnd },
})
: await this.prisma.partnerBill.create({
data: {
billNo: generateBillNo(),
partnerId,
periodStart,
periodEnd,
orderCommission,
redeemCommission,
totalAmount,
status: 'DRAFT',
},
});
return serializeBigInt(bill);
}
async confirmPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
});
await this.prisma.commonEvent.create({
data: {
eventType: 'HQ_OPERATION',
refType: 'PARTNER_BILL',
refId: id,
actorType: 'HQ',
status: 'CONFIRMED',
amount1: Number(updated.totalAmount),
},
});
return serializeBigInt(updated);
}
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
await this.prisma.commonEvent.create({
data: {
eventType: 'HQ_OPERATION',
refType: 'PARTNER_BILL',
refId: id,
actorType: 'HQ',
status: 'PAID',
param1: dto.paymentRef ?? '',
amount1: Number(updated.totalAmount),
},
});
return serializeBigInt(updated);
}
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
const where: Prisma.PartnerBillWhereInput = {};
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partner: { select: { companyName: true } } },
orderBy: { createdAt: 'desc' },
});
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
const rows = bills.map((b) =>
[
b.billNo,
b.partner.companyName,
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission),
Number(b.redeemCommission),
Number(b.totalAmount),
b.status,
].join(','),
);
return { csv: [header, ...rows].join('\n'), count: bills.length };
}
async scanDueStorePayouts() {
const now = new Date();
const due = await this.prisma.storePayout.findMany({
where: { status: 'PENDING', expectedPayAt: { lte: now } },
take: 100,
});
return serializeBigInt({ dueCount: due.length, items: due });
}
}
@@ -55,6 +55,15 @@ export class TradeController {
confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.tradeService.confirmReceive(user.actorId, BigInt(id));
}
@Post(':id/refund-requests')
refundRequest(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { remark?: string },
) {
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
}
}
@Controller('partner/orders')
@@ -85,3 +94,14 @@ export class PartnerOrderController {
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
}
}
@Controller('partner/reshipments')
@UseGuards(JwtAuthGuard)
export class PartnerReshipmentController {
constructor(private readonly tradeService: TradeService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.tradeService.listPartnerReshipments(user.actorId);
}
}
@@ -2,12 +2,14 @@ import { Module, forwardRef } from '@nestjs/common';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { TradeController, PartnerOrderController } from './trade.controller';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
controllers: [TradeController, PartnerOrderController],
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService],
exports: [TradeService],
})
@@ -14,7 +14,9 @@ import {
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
@@ -29,16 +31,16 @@ import type { Request } from 'express';
export class TradeService {
constructor(
private readonly prisma: PrismaService,
private readonly catalogService: CatalogService,
private readonly benefitService: BenefitService,
private readonly ticketService: TicketService,
private readonly ipGeoService: IpGeoService,
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: BigInt(body.productId) },
});
const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
@@ -73,7 +75,7 @@ export class TradeService {
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
return {
product: serializeBigInt(product),
product,
quantity: body.quantity,
deliveryType,
productAmount,
@@ -384,6 +386,47 @@ export class TradeService {
return this.getOrder(userId, orderId);
}
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在');
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
throw new BadRequestException('当前订单不可申请退款');
}
await this.prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
});
return this.ticketService.create({
ticketType: 'REFUND',
refType: 'ORDER',
refId: orderId.toString(),
remark: remark ?? '用户申请退款',
});
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
select: { id: true },
});
const tickets = await this.prisma.commonTicket.findMany({
where: {
ticketType: 'RESHIPMENT',
refType: 'ORDER',
refId: { in: orders.map((o) => o.id) },
},
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(tickets);
}
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },