feat(core): 按照严格TDD模式完成活动项目(4位核销码)、订单状态机、财务分销与双身份切换核心领域服务及30项单元测试
这个提交包含在:
9
server/jest.config.js
普通文件
9
server/jest.config.js
普通文件
@@ -0,0 +1,9 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.spec.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
|
||||
},
|
||||
};
|
||||
22
server/package.json
普通文件
22
server/package.json
普通文件
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "peiwan-server",
|
||||
"version": "1.0.0",
|
||||
"description": "陪玩小程序核心业务服务端",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"test": "jest --verbose",
|
||||
"test:watch": "jest --watch",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"keywords": ["peiwan", "game", "activity", "tdd"],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.13.4",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
2589
server/pnpm-lock.yaml
自动生成的
普通文件
2589
server/pnpm-lock.yaml
自动生成的
普通文件
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,133 @@
|
||||
export enum ActivityPayType {
|
||||
AA_PAY = 1, // AA制付费
|
||||
PROJECT_PAY = 2, // 项目制付费
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
creatorId: number;
|
||||
title: string;
|
||||
gameCategory: string;
|
||||
payType: ActivityPayType;
|
||||
totalFeeCents?: number;
|
||||
aaFeeCents?: number;
|
||||
maxParticipants: number;
|
||||
currentParticipants: number;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
activityId: string;
|
||||
userId: number;
|
||||
payAmountCents: number;
|
||||
verifyCode: string; // 4位纯数字核销码
|
||||
checkinStatus: boolean;
|
||||
verifiedAt?: Date;
|
||||
verifiedBy?: number;
|
||||
}
|
||||
|
||||
export class ActivityService {
|
||||
private activities = new Map<string, Activity>();
|
||||
private participants = new Map<string, Participant[]>(); // activityId -> participants
|
||||
|
||||
createActivity(params: {
|
||||
creatorId: number;
|
||||
title: string;
|
||||
gameCategory: string;
|
||||
payType: ActivityPayType;
|
||||
totalFeeCents?: number;
|
||||
aaFeeCents?: number;
|
||||
maxParticipants: number;
|
||||
}): Activity {
|
||||
const id = `act_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
const activity: Activity = {
|
||||
id,
|
||||
creatorId: params.creatorId,
|
||||
title: params.title,
|
||||
gameCategory: params.gameCategory,
|
||||
payType: params.payType,
|
||||
totalFeeCents: params.totalFeeCents,
|
||||
aaFeeCents: params.aaFeeCents,
|
||||
maxParticipants: params.maxParticipants,
|
||||
currentParticipants: 0,
|
||||
};
|
||||
this.activities.set(id, activity);
|
||||
this.participants.set(id, []);
|
||||
return activity;
|
||||
}
|
||||
|
||||
joinActivity(activityId: string, params: { userId: number; payAmountCents: number }): Participant {
|
||||
const activity = this.activities.get(activityId);
|
||||
if (!activity) {
|
||||
throw new Error('活动不存在');
|
||||
}
|
||||
|
||||
const currentList = this.participants.get(activityId) || [];
|
||||
if (activity.maxParticipants > 0 && currentList.length >= activity.maxParticipants) {
|
||||
throw new Error('活动报名人数已满');
|
||||
}
|
||||
|
||||
const alreadyJoined = currentList.some((p) => p.userId === params.userId);
|
||||
if (alreadyJoined) {
|
||||
throw new Error('用户已报名该活动');
|
||||
}
|
||||
|
||||
// 生成当前活动内唯一的4位数字核销码 (1000-9999)
|
||||
const existingCodes = new Set(currentList.map((p) => p.verifyCode));
|
||||
let verifyCode = '';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const code = String(Math.floor(1000 + Math.random() * 9000));
|
||||
if (!existingCodes.has(code)) {
|
||||
verifyCode = code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!verifyCode) {
|
||||
throw new Error('核销码生成失败,池已满');
|
||||
}
|
||||
|
||||
const participant: Participant = {
|
||||
id: `p_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
activityId,
|
||||
userId: params.userId,
|
||||
payAmountCents: params.payAmountCents,
|
||||
verifyCode,
|
||||
checkinStatus: false,
|
||||
};
|
||||
|
||||
currentList.push(participant);
|
||||
activity.currentParticipants = currentList.length;
|
||||
return participant;
|
||||
}
|
||||
|
||||
verifyCheckin(params: {
|
||||
activityId: string;
|
||||
operatorId: number;
|
||||
verifyCode: string;
|
||||
}): { success: boolean; participant: Participant } {
|
||||
const activity = this.activities.get(params.activityId);
|
||||
if (!activity) {
|
||||
throw new Error('活动不存在');
|
||||
}
|
||||
|
||||
if (activity.creatorId !== params.operatorId) {
|
||||
throw new Error('无权核销该活动的核销码');
|
||||
}
|
||||
|
||||
const currentList = this.participants.get(params.activityId) || [];
|
||||
const participant = currentList.find((p) => p.verifyCode === params.verifyCode);
|
||||
if (!participant) {
|
||||
throw new Error('无效或不存在的核销码');
|
||||
}
|
||||
|
||||
if (participant.checkinStatus) {
|
||||
throw new Error('该核销码已被核销,不可重复使用');
|
||||
}
|
||||
|
||||
participant.checkinStatus = true;
|
||||
participant.verifiedAt = new Date();
|
||||
participant.verifiedBy = params.operatorId;
|
||||
|
||||
return { success: true, participant };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export enum UserRoleFlag {
|
||||
NONE = 0,
|
||||
PLAYER = 1 << 0, // 1
|
||||
EXPERT = 1 << 1, // 2
|
||||
}
|
||||
|
||||
export enum WorkMode {
|
||||
PLAYER = 'PLAYER',
|
||||
EXPERT = 'EXPERT',
|
||||
}
|
||||
|
||||
export enum CertStatus {
|
||||
NONE = 0, // 未提交
|
||||
PENDING = 1, // 待审核
|
||||
APPROVED = 2, // 审核通过
|
||||
REJECTED = 3, // 驳回
|
||||
}
|
||||
|
||||
export interface UserAccount {
|
||||
id: number;
|
||||
openid: string;
|
||||
nickname: string;
|
||||
roleFlags: number;
|
||||
currentMode: WorkMode;
|
||||
}
|
||||
|
||||
export class AuthRoleManager {
|
||||
private users = new Map<number, UserAccount>();
|
||||
private certs = new Map<number, { status: CertStatus; realName?: string; idCardNo?: string }>();
|
||||
private autoIncId = 1000;
|
||||
|
||||
registerUser(params: { openid: string; nickname: string }): UserAccount {
|
||||
const id = ++this.autoIncId;
|
||||
const user: UserAccount = {
|
||||
id,
|
||||
openid: params.openid,
|
||||
nickname: params.nickname,
|
||||
roleFlags: UserRoleFlag.PLAYER,
|
||||
currentMode: WorkMode.PLAYER,
|
||||
};
|
||||
this.users.set(id, user);
|
||||
this.certs.set(id, { status: CertStatus.NONE });
|
||||
return user;
|
||||
}
|
||||
|
||||
getUser(userId: number): UserAccount {
|
||||
const user = this.users.get(userId);
|
||||
if (!user) throw new Error('用户不存在');
|
||||
return user;
|
||||
}
|
||||
|
||||
getCertStatus(userId: number): CertStatus {
|
||||
return this.certs.get(userId)?.status ?? CertStatus.NONE;
|
||||
}
|
||||
|
||||
submitExpertCert(userId: number, info: { realName: string; idCardNo: string }) {
|
||||
this.certs.set(userId, {
|
||||
status: CertStatus.PENDING,
|
||||
realName: info.realName,
|
||||
idCardNo: info.idCardNo,
|
||||
});
|
||||
}
|
||||
|
||||
approveExpertCert(userId: number) {
|
||||
const cert = this.certs.get(userId);
|
||||
if (!cert) throw new Error('未找到认证申请');
|
||||
cert.status = CertStatus.APPROVED;
|
||||
|
||||
const user = this.getUser(userId);
|
||||
user.roleFlags |= UserRoleFlag.EXPERT;
|
||||
}
|
||||
|
||||
switchMode(userId: number, targetMode: WorkMode) {
|
||||
const user = this.getUser(userId);
|
||||
if (targetMode === WorkMode.EXPERT && !(user.roleFlags & UserRoleFlag.EXPERT)) {
|
||||
throw new Error('尚未开通或通过大神认证,无法切换至接单模式');
|
||||
}
|
||||
user.currentMode = targetMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export class FinanceCalculator {
|
||||
/**
|
||||
* 计算平台抽成与大神实际分润所得
|
||||
*/
|
||||
splitOrderIncome(params: {
|
||||
orderAmountCents: number;
|
||||
platformRatePercent: number;
|
||||
}): { platformFeeCents: number; expertIncomeCents: number } {
|
||||
const platformFeeCents = Math.floor((params.orderAmountCents * params.platformRatePercent) / 100);
|
||||
const expertIncomeCents = params.orderAmountCents - platformFeeCents;
|
||||
return { platformFeeCents, expertIncomeCents };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算二级分销佣金
|
||||
*/
|
||||
calculateDistribution(params: {
|
||||
orderAmountCents: number;
|
||||
level1Percent: number;
|
||||
level2Percent: number;
|
||||
hasLevel1: boolean;
|
||||
hasLevel2: boolean;
|
||||
}): { level1Cents: number; level2Cents: number; totalCommissionCents: number } {
|
||||
const level1Cents = params.hasLevel1
|
||||
? Math.floor((params.orderAmountCents * params.level1Percent) / 100)
|
||||
: 0;
|
||||
const level2Cents = params.hasLevel2
|
||||
? Math.floor((params.orderAmountCents * params.level2Percent) / 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
level1Cents,
|
||||
level2Cents,
|
||||
totalCommissionCents: level1Cents + level2Cents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用优惠券抵扣
|
||||
*/
|
||||
applyCoupon(params: {
|
||||
orderAmountCents: number;
|
||||
couponDiscountCents: number;
|
||||
minOrderThresholdCents: number;
|
||||
}): number {
|
||||
if (params.orderAmountCents < params.minOrderThresholdCents) {
|
||||
throw new Error('订单未达到优惠券使用门槛');
|
||||
}
|
||||
const payAmount = params.orderAmountCents - params.couponDiscountCents;
|
||||
return Math.max(0, payAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现校验与实际到账金额计算
|
||||
*/
|
||||
calculateWithdrawal(params: {
|
||||
withdrawAmountCents: number;
|
||||
minLimitCents: number;
|
||||
feePercent: number;
|
||||
}): { feeCents: number; actualArrivalCents: number } {
|
||||
if (params.withdrawAmountCents < params.minLimitCents) {
|
||||
throw new Error('提现金额低于平台最低提现限额');
|
||||
}
|
||||
|
||||
const feeCents = Math.floor((params.withdrawAmountCents * params.feePercent) / 100);
|
||||
const actualArrivalCents = params.withdrawAmountCents - feeCents;
|
||||
|
||||
return { feeCents, actualArrivalCents };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export enum OrderStatus {
|
||||
PENDING_PAYMENT = 'PENDING_PAYMENT', // 待付款
|
||||
PENDING_ACCEPT = 'PENDING_ACCEPT', // 待接单
|
||||
IN_SERVICE = 'IN_SERVICE', // 服务中
|
||||
PENDING_CONFIRM = 'PENDING_CONFIRM', // 待确认
|
||||
COMPLETED = 'COMPLETED', // 已完成
|
||||
CANCELLED = 'CANCELLED', // 已取消
|
||||
DISPUTED = 'DISPUTED', // 仲裁中
|
||||
REFUNDED = 'REFUNDED', // 已退款
|
||||
}
|
||||
|
||||
export enum OrderEvent {
|
||||
PAY_SUCCESS = 'PAY_SUCCESS', // 付款成功
|
||||
CANCEL = 'CANCEL', // 取消订单
|
||||
EXPERT_ACCEPT = 'EXPERT_ACCEPT', // 大神接单
|
||||
EXPERT_REJECT = 'EXPERT_REJECT', // 大神拒单或超时退款
|
||||
EXPERT_APPLY_FINISH = 'EXPERT_APPLY_FINISH', // 大神申请完成
|
||||
BUYER_CONFIRM = 'BUYER_CONFIRM', // 买家确认完成
|
||||
BUYER_DISPUTE = 'BUYER_DISPUTE', // 买家申请仲裁
|
||||
ARBITRATE_REFUND = 'ARBITRATE_REFUND', // 仲裁退款
|
||||
ARBITRATE_RELEASE = 'ARBITRATE_RELEASE', // 仲裁结单打款
|
||||
}
|
||||
|
||||
export class OrderStateMachine {
|
||||
// 状态转移表
|
||||
private transitions: Record<OrderStatus, Partial<Record<OrderEvent, OrderStatus>>> = {
|
||||
[OrderStatus.PENDING_PAYMENT]: {
|
||||
[OrderEvent.PAY_SUCCESS]: OrderStatus.PENDING_ACCEPT,
|
||||
[OrderEvent.CANCEL]: OrderStatus.CANCELLED,
|
||||
},
|
||||
[OrderStatus.PENDING_ACCEPT]: {
|
||||
[OrderEvent.EXPERT_ACCEPT]: OrderStatus.IN_SERVICE,
|
||||
[OrderEvent.EXPERT_REJECT]: OrderStatus.REFUNDED,
|
||||
},
|
||||
[OrderStatus.IN_SERVICE]: {
|
||||
[OrderEvent.EXPERT_APPLY_FINISH]: OrderStatus.PENDING_CONFIRM,
|
||||
},
|
||||
[OrderStatus.PENDING_CONFIRM]: {
|
||||
[OrderEvent.BUYER_CONFIRM]: OrderStatus.COMPLETED,
|
||||
[OrderEvent.BUYER_DISPUTE]: OrderStatus.DISPUTED,
|
||||
},
|
||||
[OrderStatus.DISPUTED]: {
|
||||
[OrderEvent.ARBITRATE_REFUND]: OrderStatus.REFUNDED,
|
||||
[OrderEvent.ARBITRATE_RELEASE]: OrderStatus.COMPLETED,
|
||||
},
|
||||
[OrderStatus.COMPLETED]: {},
|
||||
[OrderStatus.CANCELLED]: {},
|
||||
[OrderStatus.REFUNDED]: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行状态迁移
|
||||
*/
|
||||
transition(currentState: OrderStatus, event: OrderEvent): OrderStatus {
|
||||
const validNextState = this.transitions[currentState]?.[event];
|
||||
if (!validNextState) {
|
||||
throw new Error(`非法状态流转: 当前状态 [${currentState}] 无法执行事件 [${event}]`);
|
||||
}
|
||||
return validNextState;
|
||||
}
|
||||
}
|
||||
167
server/tests/activity.spec.ts
普通文件
167
server/tests/activity.spec.ts
普通文件
@@ -0,0 +1,167 @@
|
||||
import { ActivityService, ActivityPayType, Activity, Participant } from '../src/core/activity/activity.service';
|
||||
|
||||
describe('ActivityService (TDD 测试套件: 活动项目与4位核销码)', () => {
|
||||
let activityService: ActivityService;
|
||||
|
||||
beforeEach(() => {
|
||||
activityService = new ActivityService();
|
||||
});
|
||||
|
||||
describe('1. 发起活动 (AA制与项目制)', () => {
|
||||
it('应成功创建AA制活动并正确初始化', () => {
|
||||
const activity = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '王者荣耀荣耀王者五排冲星车队',
|
||||
gameCategory: '王者荣耀',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 3000, // 每人30元
|
||||
maxParticipants: 4, // 限制4人,0表示不限制
|
||||
});
|
||||
|
||||
expect(activity.id).toBeDefined();
|
||||
expect(activity.payType).toBe(ActivityPayType.AA_PAY);
|
||||
expect(activity.aaFeeCents).toBe(3000);
|
||||
expect(activity.maxParticipants).toBe(4);
|
||||
expect(activity.currentParticipants).toBe(0);
|
||||
});
|
||||
|
||||
it('应成功创建项目制活动(不限制报名人数)', () => {
|
||||
const activity = activityService.createActivity({
|
||||
creatorId: 1002,
|
||||
title: '水友粉丝狂欢内战赛',
|
||||
gameCategory: '英雄联盟',
|
||||
payType: ActivityPayType.PROJECT_PAY,
|
||||
totalFeeCents: 100000, // 发起人项目总包1000元
|
||||
maxParticipants: 0, // 不限制报名人数
|
||||
});
|
||||
|
||||
expect(activity.payType).toBe(ActivityPayType.PROJECT_PAY);
|
||||
expect(activity.totalFeeCents).toBe(100000);
|
||||
expect(activity.maxParticipants).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 报名与生成 4 位纯数字核销码', () => {
|
||||
let aaActivity: Activity;
|
||||
|
||||
beforeEach(() => {
|
||||
aaActivity = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '和平精英四排吃鸡车队',
|
||||
gameCategory: '和平精英',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 2500,
|
||||
maxParticipants: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('报名成功后应生成合法的 4 位纯数字核销码 (1000-9999)', () => {
|
||||
const participant = activityService.joinActivity(aaActivity.id, {
|
||||
userId: 2001,
|
||||
payAmountCents: 2500,
|
||||
});
|
||||
|
||||
expect(participant.verifyCode).toMatch(/^\d{4}$/);
|
||||
const codeNum = parseInt(participant.verifyCode, 10);
|
||||
expect(codeNum).toBeGreaterThanOrEqual(1000);
|
||||
expect(codeNum).toBeLessThanOrEqual(9999);
|
||||
expect(participant.checkinStatus).toBe(false);
|
||||
});
|
||||
|
||||
it('单活动内多个参与者的 4 位核销码不可重复碰撞', () => {
|
||||
const p1 = activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
|
||||
const p2 = activityService.joinActivity(aaActivity.id, { userId: 2002, payAmountCents: 2500 });
|
||||
const p3 = activityService.joinActivity(aaActivity.id, { userId: 2003, payAmountCents: 2500 });
|
||||
|
||||
const codes = new Set([p1.verifyCode, p2.verifyCode, p3.verifyCode]);
|
||||
expect(codes.size).toBe(3);
|
||||
});
|
||||
|
||||
it('同一用户不可重复报名同一活动', () => {
|
||||
activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
|
||||
|
||||
expect(() => {
|
||||
activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
|
||||
}).toThrow('用户已报名该活动');
|
||||
});
|
||||
|
||||
it('超出人数限制时应阻止报名', () => {
|
||||
activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
|
||||
activityService.joinActivity(aaActivity.id, { userId: 2002, payAmountCents: 2500 });
|
||||
activityService.joinActivity(aaActivity.id, { userId: 2003, payAmountCents: 2500 });
|
||||
|
||||
expect(() => {
|
||||
activityService.joinActivity(aaActivity.id, { userId: 2004, payAmountCents: 2500 });
|
||||
}).toThrow('活动报名人数已满');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 4位核销码签到核销与防重控制', () => {
|
||||
let testActivity: Activity;
|
||||
let participant: Participant;
|
||||
|
||||
beforeEach(() => {
|
||||
testActivity = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '双排上分活动',
|
||||
gameCategory: '王者荣耀',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 2000,
|
||||
maxParticipants: 1,
|
||||
});
|
||||
|
||||
participant = activityService.joinActivity(testActivity.id, {
|
||||
userId: 3001,
|
||||
payAmountCents: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('发起人使用正确的 4 位核销码应核销成功', () => {
|
||||
const result = activityService.verifyCheckin({
|
||||
activityId: testActivity.id,
|
||||
operatorId: 1001, // 发起人操作
|
||||
verifyCode: participant.verifyCode,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.participant.checkinStatus).toBe(true);
|
||||
expect(result.participant.verifiedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('同一 4 位核销码不可重复核销(防重签到)', () => {
|
||||
activityService.verifyCheckin({
|
||||
activityId: testActivity.id,
|
||||
operatorId: 1001,
|
||||
verifyCode: participant.verifyCode,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
activityService.verifyCheckin({
|
||||
activityId: testActivity.id,
|
||||
operatorId: 1001,
|
||||
verifyCode: participant.verifyCode,
|
||||
});
|
||||
}).toThrow('该核销码已被核销,不可重复使用');
|
||||
});
|
||||
|
||||
it('输入错误的 4 位核销码应报错拒绝', () => {
|
||||
expect(() => {
|
||||
activityService.verifyCheckin({
|
||||
activityId: testActivity.id,
|
||||
operatorId: 1001,
|
||||
verifyCode: '0000', // 不存在的核销码
|
||||
});
|
||||
}).toThrow('无效或不存在的核销码');
|
||||
});
|
||||
|
||||
it('非发起人或未授权人员核销应被拒绝', () => {
|
||||
expect(() => {
|
||||
activityService.verifyCheckin({
|
||||
activityId: testActivity.id,
|
||||
operatorId: 9999, // 非法第三方操作者
|
||||
verifyCode: participant.verifyCode,
|
||||
});
|
||||
}).toThrow('无权核销该活动的核销码');
|
||||
});
|
||||
});
|
||||
});
|
||||
52
server/tests/auth-role.spec.ts
普通文件
52
server/tests/auth-role.spec.ts
普通文件
@@ -0,0 +1,52 @@
|
||||
import { AuthRoleManager, UserRoleFlag, WorkMode, CertStatus } from '../src/core/auth/auth-role.manager';
|
||||
|
||||
describe('AuthRoleManager (TDD 测试套件: 用户与大神双身份平滑切换)', () => {
|
||||
let roleManager: AuthRoleManager;
|
||||
|
||||
beforeEach(() => {
|
||||
roleManager = new AuthRoleManager();
|
||||
});
|
||||
|
||||
describe('1. 玩家与大神身份校验', () => {
|
||||
it('普通注册用户默认仅具备玩家身份', () => {
|
||||
const user = roleManager.registerUser({ openid: 'wx_123456', nickname: '新手玩家' });
|
||||
|
||||
expect(user.roleFlags).toBe(UserRoleFlag.PLAYER);
|
||||
expect(user.currentMode).toBe(WorkMode.PLAYER);
|
||||
});
|
||||
|
||||
it('普通玩家尝试直接切换至大神接单模式应被拒绝', () => {
|
||||
const user = roleManager.registerUser({ openid: 'wx_123456', nickname: '新手玩家' });
|
||||
|
||||
expect(() => {
|
||||
roleManager.switchMode(user.id, WorkMode.EXPERT);
|
||||
}).toThrow('尚未开通或通过大神认证,无法切换至接单模式');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 认证开通与双身份平滑切换', () => {
|
||||
it('完成实名认证审核通过后,赋予双身份并允许自由切换', () => {
|
||||
const user = roleManager.registerUser({ openid: 'wx_expert_001', nickname: '电竞大神' });
|
||||
|
||||
// 提交实名认证
|
||||
roleManager.submitExpertCert(user.id, { realName: '张三', idCardNo: '520100...' });
|
||||
expect(roleManager.getCertStatus(user.id)).toBe(CertStatus.PENDING);
|
||||
|
||||
// 后台审核通过
|
||||
roleManager.approveExpertCert(user.id);
|
||||
expect(roleManager.getCertStatus(user.id)).toBe(CertStatus.APPROVED);
|
||||
|
||||
// 检查角色位图已升级为 BOTH (玩家+大神)
|
||||
const updatedUser = roleManager.getUser(user.id);
|
||||
expect(updatedUser.roleFlags).toBe(UserRoleFlag.PLAYER | UserRoleFlag.EXPERT);
|
||||
|
||||
// 切换至大神模式
|
||||
roleManager.switchMode(user.id, WorkMode.EXPERT);
|
||||
expect(roleManager.getUser(user.id).currentMode).toBe(WorkMode.EXPERT);
|
||||
|
||||
// 一键切回玩家模式
|
||||
roleManager.switchMode(user.id, WorkMode.PLAYER);
|
||||
expect(roleManager.getUser(user.id).currentMode).toBe(WorkMode.PLAYER);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
server/tests/finance.spec.ts
普通文件
118
server/tests/finance.spec.ts
普通文件
@@ -0,0 +1,118 @@
|
||||
import { FinanceCalculator } from '../src/core/finance/finance.calculator';
|
||||
|
||||
describe('FinanceCalculator (TDD 测试套件: 财务与分销计算引擎)', () => {
|
||||
let calculator: FinanceCalculator;
|
||||
|
||||
beforeEach(() => {
|
||||
calculator = new FinanceCalculator();
|
||||
});
|
||||
|
||||
describe('1. 平台抽成与大神分润计算', () => {
|
||||
it('100元订单按10%抽成,平台得10元,大神得90元', () => {
|
||||
const result = calculator.splitOrderIncome({
|
||||
orderAmountCents: 10000, // 100.00 元
|
||||
platformRatePercent: 10,
|
||||
});
|
||||
|
||||
expect(result.platformFeeCents).toBe(1000); // 10.00 元
|
||||
expect(result.expertIncomeCents).toBe(9000); // 90.00 元
|
||||
expect(result.platformFeeCents + result.expertIncomeCents).toBe(10000);
|
||||
});
|
||||
|
||||
it('出现小数分位时,向下取整平台抽成,保证资金平衡无漂移', () => {
|
||||
const result = calculator.splitOrderIncome({
|
||||
orderAmountCents: 3333, // 33.33 元
|
||||
platformRatePercent: 15, // 15% -> 499.95 分
|
||||
});
|
||||
|
||||
expect(result.platformFeeCents).toBe(499);
|
||||
expect(result.expertIncomeCents).toBe(2834);
|
||||
expect(result.platformFeeCents + result.expertIncomeCents).toBe(3333);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 二级分销佣金分润计算', () => {
|
||||
it('100元订单,一级分销商得5元,二级分销商得2元', () => {
|
||||
const comm = calculator.calculateDistribution({
|
||||
orderAmountCents: 10000,
|
||||
level1Percent: 5,
|
||||
level2Percent: 2,
|
||||
hasLevel1: true,
|
||||
hasLevel2: true,
|
||||
});
|
||||
|
||||
expect(comm.level1Cents).toBe(500); // 5.00 元
|
||||
expect(comm.level2Cents).toBe(200); // 2.00 元
|
||||
expect(comm.totalCommissionCents).toBe(700);
|
||||
});
|
||||
|
||||
it('只有一级推荐人时,仅发放一级佣金', () => {
|
||||
const comm = calculator.calculateDistribution({
|
||||
orderAmountCents: 20000,
|
||||
level1Percent: 5,
|
||||
level2Percent: 2,
|
||||
hasLevel1: true,
|
||||
hasLevel2: false,
|
||||
});
|
||||
|
||||
expect(comm.level1Cents).toBe(1000);
|
||||
expect(comm.level2Cents).toBe(0);
|
||||
expect(comm.totalCommissionCents).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 优惠券抵扣计算', () => {
|
||||
it('订单满100减20,实付80元', () => {
|
||||
const payAmount = calculator.applyCoupon({
|
||||
orderAmountCents: 10000,
|
||||
couponDiscountCents: 2000,
|
||||
minOrderThresholdCents: 8000,
|
||||
});
|
||||
|
||||
expect(payAmount).toBe(8000);
|
||||
});
|
||||
|
||||
it('未达到优惠券门槛时应抛出异常', () => {
|
||||
expect(() => {
|
||||
calculator.applyCoupon({
|
||||
orderAmountCents: 5000, // 50元
|
||||
couponDiscountCents: 2000,
|
||||
minOrderThresholdCents: 8000, // 需满80元
|
||||
});
|
||||
}).toThrow('订单未达到优惠券使用门槛');
|
||||
});
|
||||
|
||||
it('优惠券减免额大于订单总额时,实付金额应为0而不是负数', () => {
|
||||
const payAmount = calculator.applyCoupon({
|
||||
orderAmountCents: 3000,
|
||||
couponDiscountCents: 5000,
|
||||
minOrderThresholdCents: 0,
|
||||
});
|
||||
|
||||
expect(payAmount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 提现门槛与手续费校验', () => {
|
||||
it('低于最低提现额度(如50元)应抛出异常', () => {
|
||||
expect(() => {
|
||||
calculator.calculateWithdrawal({
|
||||
withdrawAmountCents: 3000, // 30元
|
||||
minLimitCents: 5000, // 最低50元
|
||||
feePercent: 1,
|
||||
});
|
||||
}).toThrow('提现金额低于平台最低提现限额');
|
||||
});
|
||||
|
||||
it('提现100元,手续费1%,实际打款99元', () => {
|
||||
const res = calculator.calculateWithdrawal({
|
||||
withdrawAmountCents: 10000,
|
||||
minLimitCents: 5000,
|
||||
feePercent: 1,
|
||||
});
|
||||
|
||||
expect(res.feeCents).toBe(100);
|
||||
expect(res.actualArrivalCents).toBe(9900);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { OrderStateMachine, OrderStatus, OrderEvent } from '../src/core/order/order-state-machine';
|
||||
|
||||
describe('OrderStateMachine (TDD 测试套件: 陪玩订单状态机驱动)', () => {
|
||||
let stateMachine: OrderStateMachine;
|
||||
|
||||
beforeEach(() => {
|
||||
stateMachine = new OrderStateMachine();
|
||||
});
|
||||
|
||||
describe('1. 正向订单履约流转', () => {
|
||||
it('正常流程: 待付款 -> 待接单 -> 服务中 -> 待确认 -> 已完成', () => {
|
||||
let state = OrderStatus.PENDING_PAYMENT;
|
||||
|
||||
state = stateMachine.transition(state, OrderEvent.PAY_SUCCESS);
|
||||
expect(state).toBe(OrderStatus.PENDING_ACCEPT);
|
||||
|
||||
state = stateMachine.transition(state, OrderEvent.EXPERT_ACCEPT);
|
||||
expect(state).toBe(OrderStatus.IN_SERVICE);
|
||||
|
||||
state = stateMachine.transition(state, OrderEvent.EXPERT_APPLY_FINISH);
|
||||
expect(state).toBe(OrderStatus.PENDING_CONFIRM);
|
||||
|
||||
state = stateMachine.transition(state, OrderEvent.BUYER_CONFIRM);
|
||||
expect(state).toBe(OrderStatus.COMPLETED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 取消与拒单退款流转', () => {
|
||||
it('待付款状态下买家取消,变为已取消', () => {
|
||||
const state = stateMachine.transition(OrderStatus.PENDING_PAYMENT, OrderEvent.CANCEL);
|
||||
expect(state).toBe(OrderStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('待接单状态下大神拒单或超时,变为已退款', () => {
|
||||
const state = stateMachine.transition(OrderStatus.PENDING_ACCEPT, OrderEvent.EXPERT_REJECT);
|
||||
expect(state).toBe(OrderStatus.REFUNDED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 售后与仲裁介入流转', () => {
|
||||
it('待确认状态下买家发起申诉仲裁,进入仲裁中', () => {
|
||||
const state = stateMachine.transition(OrderStatus.PENDING_CONFIRM, OrderEvent.BUYER_DISPUTE);
|
||||
expect(state).toBe(OrderStatus.DISPUTED);
|
||||
});
|
||||
|
||||
it('仲裁判定买家胜诉,转为已退款', () => {
|
||||
const state = stateMachine.transition(OrderStatus.DISPUTED, OrderEvent.ARBITRATE_REFUND);
|
||||
expect(state).toBe(OrderStatus.REFUNDED);
|
||||
});
|
||||
|
||||
it('仲裁驳回申诉,转为已完成', () => {
|
||||
const state = stateMachine.transition(OrderStatus.DISPUTED, OrderEvent.ARBITRATE_RELEASE);
|
||||
expect(state).toBe(OrderStatus.COMPLETED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 非法状态跃迁防御', () => {
|
||||
it('待付款状态直接跳至已完成应抛出非法操作异常', () => {
|
||||
expect(() => {
|
||||
stateMachine.transition(OrderStatus.PENDING_PAYMENT, OrderEvent.BUYER_CONFIRM);
|
||||
}).toThrow('非法状态流转');
|
||||
});
|
||||
|
||||
it('已完成订单不可再次发起退款或取消', () => {
|
||||
expect(() => {
|
||||
stateMachine.transition(OrderStatus.COMPLETED, OrderEvent.CANCEL);
|
||||
}).toThrow('非法状态流转');
|
||||
});
|
||||
});
|
||||
});
|
||||
15
server/tsconfig.json
普通文件
15
server/tsconfig.json
普通文件
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户