feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
@@ -0,0 +1,52 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
STORE_RATING_MAX_COMMENT,
STORE_RATING_MAX_IMAGES,
type SubmitStoreRatingRequest,
} from '@dukang/shared-types';
export class SubmitStoreRatingDto implements SubmitStoreRatingRequest {
@IsString()
@IsNotEmpty()
redeemRecordId: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5)
serviceScore: number;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5)
envScore: number;
@IsOptional()
@IsString()
@MaxLength(STORE_RATING_MAX_COMMENT)
comment?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(8)
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(STORE_RATING_MAX_IMAGES)
@IsString({ each: true })
imageUrls?: string[];
}
@@ -11,6 +11,7 @@ import {
RedeemPhoneSendLookupSmsDto,
} from './dto/phone-redeem.dto';
import { RedeemFailureReportDto, RedeemPendingSubmitDto } from './dto/weaknet-redeem.dto';
import { SubmitStoreRatingDto } from './dto/submit-rating.dto';
@Controller('redeem')
@UseGuards(JwtAuthGuard)
@@ -33,8 +34,8 @@ export class UserRedeemController {
}
@Post('ratings')
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
return this.redeemService.submitRating(user.actorId, body as never);
rating(@CurrentUser() user: AuthUser, @Body() body: SubmitStoreRatingDto) {
return this.redeemService.submitRating(user.actorId, body);
}
@Get('records')
@@ -49,6 +50,11 @@ export class UserRedeemController {
Number(pageSize),
);
}
@Get('records/:id')
record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.redeemService.getUserRecord(user.actorId, id);
}
}
@Controller('shop/redeem')
@@ -51,6 +51,42 @@ function maskRedeemUserLabel(phone?: string | null): string {
return '用户***';
}
function asStringList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.map((item) => String(item).trim()).filter(Boolean);
}
function normalizeRatingTags(raw: unknown): string[] {
return [...new Set(asStringList(raw))].slice(0, 8);
}
function normalizeRatingImageUrls(raw: unknown): string[] {
return asStringList(raw)
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6);
}
function mapStoreRating(
rating: {
serviceScore: number;
envScore: number;
comment?: string | null;
tags?: unknown;
imageUrls?: unknown;
createdAt?: Date;
} | null,
) {
if (!rating) return null;
return {
serviceScore: rating.serviceScore,
envScore: rating.envScore,
comment: rating.comment ?? '',
tags: asStringList(rating.tags),
imageUrls: asStringList(rating.imageUrls),
createdAt: rating.createdAt,
};
}
type PendingSnapshot = TokenPayload & {
redeemType: 'DIRECT' | 'COUPON';
};
@@ -1264,7 +1300,7 @@ export class RedeemService {
take,
include: {
store: { select: { id: true, name: true } },
rating: { select: { serviceScore: true, envScore: true } },
rating: true,
},
}),
this.prisma.redeemRecord.count({ where: { userId } }),
@@ -1278,9 +1314,7 @@ export class RedeemService {
storeId: r.storeId,
storeName: r.store?.name ?? '门店',
createdAt: r.createdAt,
rating: r.rating
? { serviceScore: r.rating.serviceScore, envScore: r.rating.envScore }
: null,
rating: mapStoreRating(r.rating),
})),
),
total,
@@ -1289,6 +1323,26 @@ export class RedeemService {
};
}
async getUserRecord(userId: bigint, recordId: string) {
const record = await this.prisma.redeemRecord.findFirst({
where: { id: BigInt(recordId), userId },
include: {
store: { select: { id: true, name: true } },
rating: true,
},
});
if (!record) throw new NotFoundException('核销记录不存在');
return serializeBigInt({
id: record.id,
redeemNo: record.redeemNo,
amount: Number(record.amount),
storeId: record.storeId,
storeName: record.store?.name ?? '门店',
createdAt: record.createdAt,
rating: mapStoreRating(record.rating),
});
}
/** C 端门店详情走马灯:脱敏用户 + 时间 + 金额 */
async listPublicStoreRecentRedeems(storeId: bigint, limit = 20) {
const take = Math.min(Math.max(limit, 1), 50);
@@ -1334,7 +1388,17 @@ export class RedeemService {
});
}
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
async submitRating(
userId: bigint,
body: {
redeemRecordId: string;
serviceScore: number;
envScore: number;
comment?: string;
tags?: string[];
imageUrls?: string[];
},
) {
const record = await this.prisma.redeemRecord.findFirst({
where: { id: BigInt(body.redeemRecordId), userId },
});
@@ -1342,15 +1406,21 @@ export class RedeemService {
const existing = await this.prisma.storeRating.findUnique({
where: { redeemRecordId: record.id },
});
if (existing) return serializeBigInt(existing);
if (existing) throw new BadRequestException('该笔核销已评价');
const tags = normalizeRatingTags(body.tags);
const imageUrls = normalizeRatingImageUrls(body.imageUrls);
const comment = String(body.comment || '').trim().slice(0, 200) || null;
const rating = await this.prisma.storeRating.create({
data: {
redeemRecordId: record.id,
storeId: record.storeId,
serviceScore: body.serviceScore,
envScore: body.envScore,
comment,
tags,
imageUrls,
},
});
return serializeBigInt(rating);
return serializeBigInt(mapStoreRating(rating));
}
}