feat: 完善对象存储上传与真实条码二维码引擎,彻底剥离保底Mock数据

这个提交包含在:
xcbyy
2026-10-09 17:18:14 +08:00
父节点 6d3076cdb8
当前提交 d0f4050cdc
共有 329 个文件被更改,包括 54416 次插入 和 926 次删除

11
server/.env.example 普通文件
查看文件

@@ -0,0 +1,11 @@
# 陪玩平台后端数据库配置模版
PORT=3000
# 宝塔 MySQL 数据库配置
# 若部署在宝塔同台服务器,MYSQL_HOST 可保持 127.0.0.1
# 若跨服务器连接,请填写宝塔服务器公网 IP,并在宝塔安全组放行 3306 端口
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=peiwan
MYSQL_PASSWORD=你的宝塔数据库密码
MYSQL_DATABASE=peiwan

查看文件

@@ -4,19 +4,38 @@
"description": "陪玩小程序核心业务服务端",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"test": "jest --verbose",
"test:watch": "jest --watch",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/src/index.js"
},
"keywords": ["peiwan", "game", "activity", "tdd"],
"keywords": [
"peiwan",
"game",
"activity",
"tdd"
],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14",
"@types/multer": "^2.3.0",
"@types/node": "^22.13.4",
"jest": "^29.7.0",
"prisma": "8.0.0-rc.17",
"ts-jest": "^29.2.5",
"tsx": "^4.23.15",
"typescript": "^5.7.3"
},
"dependencies": {
"@prisma/client": "^7.10.0",
"cors": "^2.8.6",
"dotenv": "^18.0.4",
"express": "^5.2.1",
"multer": "^2.4.0",
"mysql2": "^3.24.4"
}
}

4950
server/pnpm-lock.yaml 自动生成的

文件差异内容过多而无法显示 加载差异

查看文件

@@ -1,3 +1,5 @@
import { UserRoleFlag } from '../auth/auth-role.manager';
export enum ActivityPayType {
AA_PAY = 1, // AA制付费
PROJECT_PAY = 2, // 项目制付费
@@ -12,8 +14,18 @@ export interface Activity {
payType: ActivityPayType;
totalFeeCents?: number;
aaFeeCents?: number;
perPersonFeeCents?: number;
allowDynamicPrice?: boolean;
priceAdjustRule?: 'UNIFIED_HIGH' | 'TIERED';
priceAdjustHistory?: Array<{
oldPriceCents: number;
newPriceCents: number;
adjustedAt: Date;
operatorId: number;
}>;
maxParticipants: number;
currentParticipants: number;
status?: 'RECRUITING' | 'FULL' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
}
export interface Participant {
@@ -39,8 +51,15 @@ export class ActivityService {
payType: ActivityPayType;
totalFeeCents?: number;
aaFeeCents?: number;
perPersonFeeCents?: number;
allowDynamicPrice?: boolean;
priceAdjustRule?: 'UNIFIED_HIGH' | 'TIERED';
maxParticipants: number;
roleManager?: any;
}): Activity {
if (params.roleManager && typeof params.roleManager.grantRole === 'function') {
params.roleManager.grantRole(params.creatorId, UserRoleFlag.HOST);
}
const id = `act_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const activity: Activity = {
id,
@@ -51,8 +70,13 @@ export class ActivityService {
payType: params.payType,
totalFeeCents: params.totalFeeCents,
aaFeeCents: params.aaFeeCents,
perPersonFeeCents: params.perPersonFeeCents,
allowDynamicPrice: params.allowDynamicPrice ?? true,
priceAdjustRule: params.priceAdjustRule || 'UNIFIED_HIGH',
priceAdjustHistory: [],
maxParticipants: params.maxParticipants,
currentParticipants: 0,
status: 'RECRUITING',
};
this.activities.set(id, activity);
this.participants.set(id, []);
@@ -133,4 +157,186 @@ export class ActivityService {
return { success: true, participant };
}
adjustActivityPrice(params: {
activityId: string;
operatorId: number;
newPriceCents: number;
adjustRule?: 'UNIFIED_HIGH' | 'TIERED';
}): { activity: Activity; priceDiffCents: number; affectedParticipantsCount: number } {
const activity = this.activities.get(params.activityId);
if (!activity) {
throw new Error('活动不存在');
}
if (activity.creatorId !== params.operatorId) {
throw new Error('只有发起人有权调整活动出价');
}
if (activity.allowDynamicPrice === false) {
throw new Error('该活动未开启动态调价权限');
}
if (params.newPriceCents <= 0) {
throw new Error('调整后的价格必须大于0');
}
const currentPriceCents = activity.perPersonFeeCents || activity.aaFeeCents || 0;
const priceDiffCents = params.newPriceCents - currentPriceCents;
const rule = params.adjustRule || activity.priceAdjustRule || 'UNIFIED_HIGH';
const currentList = this.participants.get(params.activityId) || [];
let affectedParticipantsCount = 0;
if (rule === 'UNIFIED_HIGH') {
for (const p of currentList) {
p.payAmountCents = params.newPriceCents;
}
affectedParticipantsCount = currentList.length;
}
if (!activity.priceAdjustHistory) {
activity.priceAdjustHistory = [];
}
activity.priceAdjustHistory.push({
oldPriceCents: currentPriceCents,
newPriceCents: params.newPriceCents,
adjustedAt: new Date(),
operatorId: params.operatorId,
});
if (activity.payType === ActivityPayType.AA_PAY) {
activity.aaFeeCents = params.newPriceCents;
activity.totalFeeCents = params.newPriceCents * Math.max(1, activity.maxParticipants);
} else {
activity.perPersonFeeCents = params.newPriceCents;
activity.aaFeeCents = params.newPriceCents;
activity.totalFeeCents = params.newPriceCents * Math.max(1, activity.maxParticipants);
}
return { activity, priceDiffCents, affectedParticipantsCount };
}
getAllActivities(): Activity[] {
return Array.from(this.activities.values());
}
getActivity(id: string): Activity | undefined {
return this.activities.get(id);
}
getParticipants(activityId: string): Participant[] {
return this.participants.get(activityId) || [];
}
cancelActivity(params: {
activityId: string;
operatorId: number;
reason?: string;
}): {
success: boolean;
status: string;
refundedCount: number;
totalRefundCents: number;
refundDetails: Array<{ userId: number; refundAmountCents: number }>;
} {
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 hasCheckedIn = currentList.some((p) => p.checkinStatus);
if (hasCheckedIn) {
throw new Error('聚会已有成员到场核销,不可随意解散');
}
const refundDetails = currentList.map((p) => ({
userId: p.userId,
refundAmountCents: p.payAmountCents,
}));
const totalRefundCents = refundDetails.reduce((sum, r) => sum + r.refundAmountCents, 0);
activity.status = 'CANCELLED';
return {
success: true,
status: 'CANCELLED',
refundedCount: refundDetails.length,
totalRefundCents,
refundDetails,
};
}
leaveActivity(params: {
activityId: string;
userId: number;
}): {
success: boolean;
refundAmountCents: number;
} {
const activity = this.activities.get(params.activityId);
if (!activity) {
throw new Error('活动不存在');
}
const currentList = this.participants.get(params.activityId) || [];
const ptIndex = currentList.findIndex((p) => p.userId === params.userId);
if (ptIndex === -1) {
throw new Error('用户未报名该活动');
}
const pt = currentList[ptIndex];
if (pt.checkinStatus) {
throw new Error('已核销到场,无法申请退席');
}
const refundAmountCents = pt.payAmountCents;
currentList.splice(ptIndex, 1);
activity.currentParticipants = Math.max(0, activity.currentParticipants - 1);
return {
success: true,
refundAmountCents,
};
}
adminForceCancelActivity(params: {
activityId: string;
operatorAdminId: number;
reason?: string;
roleManager: any;
}): {
success: boolean;
status: string;
refundedCount: number;
totalRefundCents: number;
refundDetails: Array<{ userId: number; refundAmountCents: number }>;
} {
if (!params.roleManager || !params.roleManager.isAdmin(params.operatorAdminId)) {
throw new Error('只有平台管理员有权执行平台强制解散');
}
const activity = this.activities.get(params.activityId);
if (!activity) {
throw new Error('活动不存在');
}
const currentList = this.participants.get(params.activityId) || [];
const refundDetails = currentList.map((p) => ({
userId: p.userId,
refundAmountCents: p.payAmountCents,
}));
const totalRefundCents = refundDetails.reduce((sum, r) => sum + r.refundAmountCents, 0);
activity.status = 'CANCELLED';
return {
success: true,
status: 'CANCELLED',
refundedCount: refundDetails.length,
totalRefundCents,
refundDetails,
};
}
}

查看文件

@@ -1,7 +1,9 @@
export enum UserRoleFlag {
NONE = 0,
PLAYER = 1 << 0, // 1
EXPERT = 1 << 1, // 2
PLAYER = 1 << 0, // 1: 普通玩家/买家
EXPERT = 1 << 1, // 2: 陪玩大神/接单人
HOST = 1 << 2, // 4: 车队房主/局长
ADMIN = 1 << 3, // 8: 平台管理员/运营端
}
export enum WorkMode {
@@ -24,57 +26,181 @@ export interface UserAccount {
currentMode: WorkMode;
}
export interface ExpertCertRecord {
userId: number;
status: CertStatus;
realName?: string;
idCardNo?: string;
rejectReason?: string;
appliedAt: Date;
auditedAt?: Date;
auditedBy?: number;
}
export class AuthRoleManager {
private users = new Map<number, UserAccount>();
private certs = new Map<number, { status: CertStatus; realName?: string; idCardNo?: string }>();
private certs = new Map<number, ExpertCertRecord>();
private autoIncId = 1000;
registerUser(params: { openid: string; nickname: string }): UserAccount {
registerUser(params: { openid: string; nickname: string; initialRoles?: number }): UserAccount {
const id = ++this.autoIncId;
const user: UserAccount = {
id,
openid: params.openid,
nickname: params.nickname,
roleFlags: UserRoleFlag.PLAYER,
roleFlags: params.initialRoles ?? UserRoleFlag.PLAYER,
currentMode: WorkMode.PLAYER,
};
this.users.set(id, user);
this.certs.set(id, { status: CertStatus.NONE });
this.certs.set(id, {
userId: id,
status: CertStatus.NONE,
appliedAt: new Date(),
});
return user;
}
registerAdmin(params: { openid: string; nickname: string }): UserAccount {
return this.registerUser({
openid: params.openid,
nickname: params.nickname,
initialRoles: UserRoleFlag.PLAYER | UserRoleFlag.ADMIN,
});
}
getUser(userId: number): UserAccount {
const user = this.users.get(userId);
if (!user) throw new Error('用户不存在');
return user;
}
hasRole(userId: number, role: UserRoleFlag): boolean {
const user = this.users.get(userId);
if (!user) return false;
return (user.roleFlags & role) === role;
}
grantRole(userId: number, role: UserRoleFlag): void {
const user = this.getUser(userId);
user.roleFlags |= role;
}
revokeRole(userId: number, role: UserRoleFlag): void {
const user = this.getUser(userId);
user.roleFlags &= ~role;
}
isPlayer(userId: number): boolean {
return this.hasRole(userId, UserRoleFlag.PLAYER);
}
isExpert(userId: number): boolean {
return this.hasRole(userId, UserRoleFlag.EXPERT);
}
isHost(userId: number): boolean {
return this.hasRole(userId, UserRoleFlag.HOST);
}
isAdmin(userId: number): boolean {
return this.hasRole(userId, UserRoleFlag.ADMIN);
}
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,
});
getCertRecord(userId: number): ExpertCertRecord | undefined {
return this.certs.get(userId);
}
approveExpertCert(userId: number) {
submitExpertCert(userId: number, info: { realName: string; idCardNo: string }): ExpertCertRecord {
if (!info.realName || !info.realName.trim()) {
throw new Error('真实姓名不能为空');
}
if (!info.idCardNo || !info.idCardNo.trim()) {
throw new Error('身份证号不能为空');
}
const record: ExpertCertRecord = {
userId,
status: CertStatus.PENDING,
realName: info.realName.trim(),
idCardNo: info.idCardNo.trim(),
appliedAt: new Date(),
};
this.certs.set(userId, record);
return record;
}
approveExpertCert(userId: number, operatorAdminId?: number): ExpertCertRecord {
if (operatorAdminId !== undefined && !this.isAdmin(operatorAdminId)) {
throw new Error('只有平台管理员有权审核大神认证申请');
}
const cert = this.certs.get(userId);
if (!cert) throw new Error('未找到认证申请');
cert.status = CertStatus.APPROVED;
cert.auditedAt = new Date();
if (operatorAdminId !== undefined) {
cert.auditedBy = operatorAdminId;
}
const user = this.getUser(userId);
user.roleFlags |= UserRoleFlag.EXPERT;
return cert;
}
switchMode(userId: number, targetMode: WorkMode) {
rejectExpertCert(userId: number, reason: string, operatorAdminId?: number): ExpertCertRecord {
if (operatorAdminId !== undefined && !this.isAdmin(operatorAdminId)) {
throw new Error('只有平台管理员有权审核大神认证申请');
}
const cert = this.certs.get(userId);
if (!cert) throw new Error('未找到认证申请');
cert.status = CertStatus.REJECTED;
cert.rejectReason = reason;
cert.auditedAt = new Date();
if (operatorAdminId !== undefined) {
cert.auditedBy = operatorAdminId;
}
return cert;
}
switchMode(userId: number, targetMode: WorkMode): void {
const user = this.getUser(userId);
if (targetMode === WorkMode.EXPERT && !(user.roleFlags & UserRoleFlag.EXPERT)) {
throw new Error('尚未开通或通过大神认证,无法切换至接单模式');
}
user.currentMode = targetMode;
}
checkCanClaimOrder(userId: number): void {
const user = this.getUser(userId);
if (!(user.roleFlags & UserRoleFlag.EXPERT) || this.getCertStatus(userId) !== CertStatus.APPROVED) {
throw new Error('未认证大神无法接单');
}
}
checkCanAudit(operatorId: number): void {
if (!this.isAdmin(operatorId)) {
throw new Error('只有平台管理员有权审核大神认证申请');
}
}
checkCanArbitrate(operatorId: number): void {
if (!this.isAdmin(operatorId)) {
throw new Error('只有平台管理员有权执行争议仲裁');
}
}
checkCanAdjustPrice(operatorId: number, creatorId: number): void {
if (operatorId !== creatorId && !this.isAdmin(operatorId)) {
throw new Error('只有发起人有权调整活动出价');
}
}
checkCanDissolveActivity(operatorId: number, creatorId: number): void {
if (operatorId !== creatorId && !this.isAdmin(operatorId)) {
throw new Error('只有发起人有权解散此组局');
}
}
}

查看文件

@@ -1,5 +1,6 @@
import { OrderStatus } from './order-state-machine';
import { CompanionTrack, ExpenseBurdenType } from '../companion/service-category';
import { AuthRoleManager, CertStatus } from '../auth/auth-role.manager';
export interface GameDetails {
gameName: string;
@@ -38,6 +39,13 @@ export interface CompanionOrder {
offlineDetails?: OfflineDetails;
offlineVerifyCode?: string;
checkedInAt?: Date;
completedAt?: Date;
disputeReason?: string;
disputedAt?: Date;
arbitratedAt?: Date;
arbitratedBy?: number;
arbitrationDecision?: 'REFUND' | 'RELEASE';
arbitrationReason?: string;
createdAt: Date;
}
@@ -75,9 +83,12 @@ export class CompanionOrderService {
return order;
}
payOrder(orderId: string): CompanionOrder {
payOrder(orderId: string, buyerId?: number): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (buyerId !== undefined && order.buyerId !== buyerId) {
throw new Error('非下单买家无权支付该订单');
}
if (order.status !== OrderStatus.PENDING_PAYMENT) {
throw new Error(`订单状态 [${order.status}] 不允许支付`);
}
@@ -86,9 +97,30 @@ export class CompanionOrderService {
return order;
}
expertAccept(orderId: string, expertId: number): CompanionOrder {
buyerCancel(orderId: string, buyerId: number): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.buyerId !== buyerId) {
throw new Error('只有下单买家有权取消订单');
}
if (order.status !== OrderStatus.PENDING_PAYMENT) {
throw new Error(`订单当前状态 [${order.status}] 不可取消`);
}
order.status = OrderStatus.CANCELLED;
return order;
}
expertAccept(orderId: string, expertId: number, roleManager?: AuthRoleManager): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (roleManager) {
if (!roleManager.isExpert(expertId) || roleManager.getCertStatus(expertId) !== CertStatus.APPROVED) {
throw new Error('未认证大神无法接单');
}
}
if (order.expertId !== expertId) throw new Error('非接单大神无权操作');
if (order.status !== OrderStatus.PENDING_ACCEPT) {
throw new Error(`订单当前状态 [${order.status}] 不可接单`);
@@ -106,6 +138,20 @@ export class CompanionOrderService {
return order;
}
expertReject(orderId: string, expertId: number, reason?: string): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.expertId !== expertId) {
throw new Error('只有指定接单大神有权拒单');
}
if (order.status !== OrderStatus.PENDING_ACCEPT) {
throw new Error(`订单当前状态 [${order.status}] 不可拒单`);
}
order.status = OrderStatus.REFUNDED;
return order;
}
verifyOfflineCheckin(params: {
orderId: string;
operatorExpertId: number;
@@ -131,4 +177,99 @@ export class CompanionOrderService {
return { success: true, order };
}
expertApplyFinish(orderId: string, expertId: number): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.expertId !== expertId) {
throw new Error('只有接单大神有权申请完工结算');
}
if (order.status !== OrderStatus.IN_SERVICE) {
throw new Error(`订单当前状态 [${order.status}] 无法申请完工结算`);
}
order.status = OrderStatus.PENDING_CONFIRM;
return order;
}
buyerConfirm(orderId: string, buyerId: number): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.buyerId !== buyerId) {
throw new Error('只有下单买家有权确认完成验收');
}
if (order.status !== OrderStatus.PENDING_CONFIRM) {
throw new Error(`订单当前状态 [${order.status}] 无法确认完成`);
}
order.completedAt = new Date();
order.status = OrderStatus.COMPLETED;
return order;
}
buyerDispute(orderId: string, buyerId: number, reason: string): CompanionOrder {
const order = this.orders.get(orderId);
if (!order) throw new Error('订单不存在');
if (order.buyerId !== buyerId) {
throw new Error('只有下单买家有权发起争议仲裁');
}
if (order.status !== OrderStatus.PENDING_CONFIRM) {
throw new Error(`订单当前状态 [${order.status}] 无法发起争议仲裁`);
}
order.disputeReason = reason;
order.disputedAt = new Date();
order.status = OrderStatus.DISPUTED;
return order;
}
arbitrate(params: {
orderId: string;
operatorAdminId: number;
decision: 'REFUND' | 'RELEASE';
reason?: string;
roleManager?: AuthRoleManager;
}): CompanionOrder {
const order = this.orders.get(params.orderId);
if (!order) throw new Error('订单不存在');
if (params.roleManager) {
if (!params.roleManager.isAdmin(params.operatorAdminId)) {
if (params.operatorAdminId === order.buyerId || params.operatorAdminId === order.expertId) {
throw new Error('买家与大神不可越权执行管理员仲裁');
}
throw new Error('只有平台管理员有权执行争议仲裁');
}
} else {
if (params.operatorAdminId === order.buyerId || params.operatorAdminId === order.expertId) {
throw new Error('买家与大神不可越权执行管理员仲裁');
}
}
if (order.status !== OrderStatus.DISPUTED) {
throw new Error(`订单当前状态 [${order.status}] 非争议仲裁中,不可执行仲裁`);
}
order.arbitratedAt = new Date();
order.arbitratedBy = params.operatorAdminId;
order.arbitrationDecision = params.decision;
order.arbitrationReason = params.reason;
if (params.decision === 'REFUND') {
order.status = OrderStatus.REFUNDED;
} else {
order.status = OrderStatus.COMPLETED;
}
return order;
}
getAllOrders(): CompanionOrder[] {
return Array.from(this.orders.values());
}
getOrder(orderId: string): CompanionOrder | undefined {
return this.orders.get(orderId);
}
}

查看文件

@@ -0,0 +1,111 @@
import { OrderStatus } from '../order/order-state-machine';
export interface OrderReviewInput {
orderId: string;
orderStatus: OrderStatus;
expertId: number;
userId: number;
userNickname: string;
rating: number;
tags?: string[];
content?: string;
}
export interface ReviewRecord {
id: string;
orderId: string;
expertId: number;
userId: number;
userNickname: string;
rating: number;
tags: string[];
content: string;
createdAt: string;
}
export interface PostCommentInput {
postId: string;
userId: number;
author: string;
content: string;
}
export interface CommentRecord {
id: string;
postId: string;
userId: number;
author: string;
content: string;
createdAt: string;
}
export class ReviewAndCommentService {
private reviews: ReviewRecord[] = [];
private comments: CommentRecord[] = [];
submitOrderReview(input: OrderReviewInput): ReviewRecord {
if (input.orderStatus !== OrderStatus.COMPLETED) {
throw new Error('只有已完成(COMPLETED)状态的订单才允许提交服务评价');
}
if (!input.rating || input.rating < 1 || input.rating > 5) {
throw new Error('评分必须在 1 至 5 星之间');
}
const exists = this.reviews.some(r => r.orderId === input.orderId);
if (exists) {
throw new Error('该订单已评价,不可重复提交评价');
}
const review: ReviewRecord = {
id: `rev_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`,
orderId: input.orderId,
expertId: input.expertId,
userId: input.userId,
userNickname: input.userNickname || '买家用户',
rating: Math.floor(input.rating),
tags: input.tags || [],
content: input.content || '好评!',
createdAt: new Date().toISOString()
};
this.reviews.push(review);
return review;
}
getReviewsByExpert(expertId: number): ReviewRecord[] {
return this.reviews.filter(r => r.expertId === expertId);
}
calculateExpertAverageRating(expertId: number, baseRating: number = 5.0): number {
const list = this.getReviewsByExpert(expertId);
if (list.length === 0) return baseRating;
const total = list.reduce((sum, item) => sum + item.rating, 0);
return Number((total / list.length).toFixed(2));
}
submitPostComment(input: PostCommentInput): CommentRecord {
if (!input.content || !input.content.trim()) {
throw new Error('评论内容不能为空');
}
if (input.content.trim().length > 500) {
throw new Error('评论内容不能超过 500 个字符');
}
const comment: CommentRecord = {
id: `cmt_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`,
postId: input.postId,
userId: input.userId,
author: input.author || '热心搭子',
content: input.content.trim(),
createdAt: new Date().toISOString()
};
this.comments.push(comment);
return comment;
}
getCommentsByPost(postId: string): CommentRecord[] {
return this.comments.filter(c => c.postId === postId);
}
}

495
server/src/db/mysql.ts 普通文件
查看文件

@@ -0,0 +1,495 @@
import 'dotenv/config';
import mysql, { Pool } from 'mysql2/promise';
export const dbConfig = {
host: process.env.MYSQL_HOST || '127.0.0.1',
port: Number(process.env.MYSQL_PORT) || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '123456',
database: process.env.MYSQL_DATABASE || 'peiwan',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
};
export const pool: Pool = mysql.createPool(dbConfig);
// 初始化所有核心数据表与种子数据
export async function initDatabase(): Promise<void> {
const conn = await pool.getConnection();
try {
// 1. 活动表
await conn.query(`
CREATE TABLE IF NOT EXISTS activities (
id VARCHAR(64) PRIMARY KEY,
creator_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
game_category VARCHAR(64) NOT NULL,
location VARCHAR(255),
pay_type INT NOT NULL DEFAULT 1,
total_fee_cents INT DEFAULT 0,
per_person_fee_cents INT DEFAULT 0,
max_participants INT DEFAULT 4,
allow_dynamic_price TINYINT DEFAULT 1,
price_adjust_rule VARCHAR(32) DEFAULT 'UNIFIED_HIGH',
status VARCHAR(32) DEFAULT 'RECRUITING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await conn.query(`ALTER TABLE activities ADD COLUMN status VARCHAR(32) DEFAULT 'RECRUITING' AFTER price_adjust_rule`);
} catch (_) {
// 字段已存在忽略
}
// 2. 参与者与4位纯数字核销码表
await conn.query(`
CREATE TABLE IF NOT EXISTS participants (
id VARCHAR(64) PRIMARY KEY,
activity_id VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
pay_amount_cents INT NOT NULL,
verify_code VARCHAR(4) NOT NULL,
checkin_status TINYINT DEFAULT 0,
verified_at DATETIME NULL,
verified_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_activity (activity_id),
INDEX idx_verify_code (verify_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 3. 陪玩大神档案表
await conn.query(`
CREATE TABLE IF NOT EXISTS companions (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
avatar TEXT,
gender VARCHAR(10),
age INT,
track VARCHAR(64) NOT NULL,
rank_name VARCHAR(64),
hourly_rate INT NOT NULL,
voice_duration INT DEFAULT 15,
voice_title VARCHAR(128),
rating DECIMAL(3,2) DEFAULT 4.90,
order_count INT DEFAULT 0,
status VARCHAR(32) DEFAULT '接单中',
audit_status VARCHAR(32) DEFAULT '已通过',
id_card_masked VARCHAR(32),
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 4. 订单表
await conn.query(`
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(64) PRIMARY KEY,
buyer_id INT DEFAULT 1001,
expert_id INT DEFAULT 101,
order_no VARCHAR(64) UNIQUE NOT NULL,
client_name VARCHAR(64) NOT NULL,
companion_name VARCHAR(64) NOT NULL,
expert_avatar TEXT,
track VARCHAR(64) NOT NULL,
service_title VARCHAR(255) DEFAULT '同城陪伴服务',
expense_burden VARCHAR(32) DEFAULT '客户全包',
hourly_rate INT NOT NULL,
duration_hours INT NOT NULL DEFAULT 2,
amount DECIMAL(10,2) NOT NULL,
location VARCHAR(255),
verify_code VARCHAR(8),
remark VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT '待支付',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 动态增加移动端所需的扩展字段 (若历史表已存在则执行防断裂补丁)
const extraOrderCols = [
"ALTER TABLE orders ADD COLUMN buyer_id INT DEFAULT 1001 AFTER id",
"ALTER TABLE orders ADD COLUMN expert_id INT DEFAULT 101 AFTER buyer_id",
"ALTER TABLE orders ADD COLUMN expert_avatar TEXT AFTER companion_name",
"ALTER TABLE orders ADD COLUMN service_title VARCHAR(255) DEFAULT '同城陪伴服务' AFTER track",
"ALTER TABLE orders ADD COLUMN verify_code VARCHAR(8) AFTER location",
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code"
];
for (const colSql of extraOrderCols) {
try {
await conn.query(colSql);
} catch (_) {
// 字段已存在则忽略
}
}
// 5. 争议仲裁表
await conn.query(`
CREATE TABLE IF NOT EXISTS arbitrations (
id VARCHAR(64) PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
order_no VARCHAR(64) NOT NULL,
client_name VARCHAR(64) NOT NULL,
companion_name VARCHAR(64) NOT NULL,
reason VARCHAR(255) NOT NULL,
user_evidence TEXT,
status VARCHAR(32) NOT NULL DEFAULT '待平台仲裁',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 6. 资金财务流水表
await conn.query(`
CREATE TABLE IF NOT EXISTS finance_records (
id VARCHAR(64) PRIMARY KEY,
record_no VARCHAR(64) UNIQUE NOT NULL,
type VARCHAR(64) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
direction VARCHAR(8) NOT NULL DEFAULT 'IN',
counterparty VARCHAR(64),
related_id VARCHAR(64),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 7. 提现申请表
await conn.query(`
CREATE TABLE IF NOT EXISTS withdrawals (
id VARCHAR(64) PRIMARY KEY,
withdraw_no VARCHAR(64) UNIQUE NOT NULL,
companion_name VARCHAR(64) NOT NULL,
companion_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
balance DECIMAL(10,2) NOT NULL,
channel VARCHAR(64) DEFAULT '微信商户零钱',
status VARCHAR(32) DEFAULT '待审核',
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 8. 线下合规场所库
await conn.query(`
CREATE TABLE IF NOT EXISTS safety_places (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
area VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL,
safety_level VARCHAR(64) DEFAULT 'AAA级合规认证',
status VARCHAR(32) DEFAULT '正常营业',
approved TINYINT DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 9. 风控拦截日志表
await conn.query(`
CREATE TABLE IF NOT EXISTS safety_audit_logs (
id VARCHAR(64) PRIMARY KEY,
timestamp_str VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
type VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
action TEXT NOT NULL,
status VARCHAR(32) DEFAULT '已处置',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 10. 财务抽成与分销配置表
await conn.query(`
CREATE TABLE IF NOT EXISTS finance_config (
id INT PRIMARY KEY DEFAULT 1,
platform_rate_percent DECIMAL(5,2) DEFAULT 10.0,
first_referral_percent DECIMAL(5,2) DEFAULT 5.0,
second_referral_percent DECIMAL(5,2) DEFAULT 2.0,
min_withdraw_amount INT DEFAULT 50,
max_withdraw_amount INT DEFAULT 20000,
withdraw_fee_percent DECIMAL(5,2) DEFAULT 0.0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 11. 全平台注册用户表 (普通玩家/认证大神/房主)
await conn.query(`
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
user_no VARCHAR(32) UNIQUE NOT NULL,
nickname VARCHAR(64) NOT NULL,
avatar TEXT,
phone VARCHAR(32) NOT NULL,
gender VARCHAR(10) DEFAULT '保密',
role VARCHAR(32) NOT NULL DEFAULT '普通玩家',
balance DECIMAL(10,2) DEFAULT 0.00,
order_count INT DEFAULT 0,
activity_count INT DEFAULT 0,
status VARCHAR(32) DEFAULT '正常',
wechat_openid VARCHAR(64),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 12. 友圈动态表
await conn.query(`
CREATE TABLE IF NOT EXISTS posts (
id VARCHAR(64) PRIMARY KEY,
user_id INT NOT NULL DEFAULT 1001,
author VARCHAR(64) NOT NULL,
avatar TEXT,
tag VARCHAR(64) DEFAULT '同城搭子',
tag_type VARCHAR(32) DEFAULT 'warm-tag',
location VARCHAR(255) DEFAULT '北京市公开商圈',
distance VARCHAR(64) DEFAULT '100m内',
content TEXT NOT NULL,
topics JSON,
images JSON,
action_text VARCHAR(32) DEFAULT '加入车队',
likes INT DEFAULT 0,
comments INT DEFAULT 0,
views INT DEFAULT 1,
category VARCHAR(32) NOT NULL DEFAULT 'partner',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 13. 订单真实评价与评分表
await conn.query(`
CREATE TABLE IF NOT EXISTS order_reviews (
id VARCHAR(64) PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
expert_id INT NOT NULL,
user_id INT NOT NULL DEFAULT 1001,
user_nickname VARCHAR(64) NOT NULL DEFAULT '买家用户',
user_avatar TEXT,
rating INT NOT NULL DEFAULT 5,
tags JSON,
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_order (order_id),
INDEX idx_expert (expert_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 14. 友圈动态互动评论表
await conn.query(`
CREATE TABLE IF NOT EXISTS post_comments (
id VARCHAR(64) PRIMARY KEY,
post_id VARCHAR(64) NOT NULL,
user_id INT NOT NULL DEFAULT 1001,
author VARCHAR(64) NOT NULL DEFAULT '热心搭子',
avatar TEXT,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_post (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 15. 友圈搭子互动约伴上车申请表
await conn.query(`
CREATE TABLE IF NOT EXISTS post_applies (
id VARCHAR(64) PRIMARY KEY,
post_id VARCHAR(64) NOT NULL,
applicant_id INT NOT NULL DEFAULT 1001,
applicant_name VARCHAR(64) NOT NULL DEFAULT '搭子玩家',
applicant_avatar TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_post_applies (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 初始化配置单条记录
const [cfgRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_config WHERE id = 1');
if (cfgRows[0].count === 0) {
await conn.query(`
INSERT INTO finance_config (id, platform_rate_percent, first_referral_percent, second_referral_percent, min_withdraw_amount, max_withdraw_amount, withdraw_fee_percent)
VALUES (1, 10.0, 5.0, 2.0, 50, 20000, 0.0);
`);
}
// 扩展 users 表结构: commission, invited_count
try {
await conn.query('ALTER TABLE users ADD COLUMN commission DECIMAL(10,2) DEFAULT 0.00');
} catch (_) {}
try {
await conn.query('ALTER TABLE users ADD COLUMN invited_count INT DEFAULT 0');
} catch (_) {}
try {
await conn.query('UPDATE users SET commission = 128.50, invited_count = 8 WHERE id = 1001 AND (commission IS NULL OR commission = 0)');
} catch (_) {}
// 检查并插入初始种子数据(若表为空)
await seedInitialDataIfEmpty(conn);
console.log('[MySQL] peiwan 数据库核心表及种子数据已完整就绪');
} finally {
conn.release();
}
}
async function seedInitialDataIfEmpty(conn: any): Promise<void> {
// 1. 种子活动
const [actRows]: any = await conn.query('SELECT COUNT(*) as count FROM activities');
if (actRows[0].count === 0) {
await conn.query(`
INSERT INTO activities (id, creator_id, title, game_category, location, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
VALUES
('act_101', 1001, '三里屯铜锅涮肉AA探店 · 8人热烈局', '同城陪吃', '北京市朝阳区三里屯聚宝源铜锅涮肉', 1, 40000, 5000, 4, 1, 'UNIFIED_HIGH'),
('act_102', 1002, '工体纯KTV沉浸式麦霸欢唱 · 寻找甜美副唱', '同城陪唱', '工体西路8号纯KTV 888包厢', 2, 240000, 80000, 3, 1, 'UNIFIED_HIGH'),
('act_103', 1003, '望京合生麒麟社微醺清吧精酿品鉴', '同城陪喝', '望京街道合生麒麟社地库跳海酒馆', 1, 39600, 19800, 4, 1, 'UNIFIED_HIGH');
`);
// 种子参与者与4位核销码
await conn.query(`
INSERT INTO participants (id, activity_id, user_id, pay_amount_cents, verify_code, checkin_status, verified_at, verified_by)
VALUES
('pt_101', 'act_101', 2001, 5000, '8392', 1, NOW(), 1001),
('pt_102', 'act_101', 2002, 5000, '5419', 0, NULL, NULL),
('pt_103', 'act_101', 2003, 5000, '7260', 0, NULL, NULL),
('pt_104', 'act_102', 2004, 80000, '9031', 0, NULL, NULL),
('pt_105', 'act_103', 2005, 19800, '6184', 0, NULL, NULL);
`);
}
// 2. 种子大神档案
const [compRows]: any = await conn.query('SELECT COUNT(*) as count FROM companions');
if (compRows[0].count === 0) {
await conn.query(`
INSERT INTO companions (name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, id_card_masked)
VALUES
('苏苏学姐', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '女', 22, '同城陪吃', '金牌探店官', 158, 18, '甜美学姐元气语音条', 4.95, 382, '接单中', '已通过', '110105********3821'),
('林深见鹿', 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', '男', 24, 'KTV陪唱', '十佳青年歌手', 288, 25, '清澈烟嗓流行情歌试听', 4.98, 512, '接单中', '已通过', '310115********1028'),
('小鱼泡泡', 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=120', '女', 21, '同城陪喝', '精酿侍酒达人', 198, 14, '微醺甜美随和聊天', 4.88, 194, '休息中', '已通过', '440301********7824'),
('顾北辰', 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=120', '男', 23, '线上电竞', '百星王者国服第一射手', 88, 12, '低沉磁性指挥型男音', 4.92, 680, '接单中', '已通过', '510107********4419'),
('可可奶芙', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120', '女', 20, '同城陪吃', '美食品鉴官', 168, 16, '软糯元气自我介绍', 4.85, 42, '待审核', '待审核', '320106********9124'),
('夜风沉醉', 'https://images.unsplash.com/photo-1522075469751-3a6694fb2f61?w=120', '男', 25, 'KTV陪唱', '摇滚乐队副主唱', 220, 22, '摇滚副歌现场高音试听', 4.75, 18, '待审核', '待审核', '330104********2816');
`);
}
// 3. 种子订单
const [ordRows]: any = await conn.query('SELECT COUNT(*) as count FROM orders');
if (ordRows[0].count === 0) {
await conn.query(`
INSERT INTO orders (id, order_no, client_name, companion_name, track, expense_burden, hourly_rate, duration_hours, amount, location, status)
VALUES
('ord_01', 'PW202609280001', '赵公子', '苏苏学姐', '同城陪吃', '客户全包', 158, 2, 316.00, '朝阳区大悦城 太二酸菜鱼', '已到场核销'),
('ord_02', 'PW202609280002', '钱老板', '林深见鹿', 'KTV陪唱', '客户全包', 288, 3, 864.00, '工体西路8号纯KTV 888包厢', '履约中'),
('ord_03', 'PW202609280003', '孙先生', '小鱼泡泡', '同城陪喝', '费用AA', 198, 2, 396.00, '望京合生麒麟社跳海酒馆', '待支付'),
('ord_04', 'PW202609280004', '李小姐', '顾北辰', '线上电竞', '无需承担', 88, 4, 352.00, '线上语音频道 #9021', '已完成'),
('ord_05', 'PW202609280005', '周先生', '林深见鹿', 'KTV陪唱', '客户全包', 288, 2, 576.00, '海淀中关村好乐迪KTV', '争议仲裁中');
`);
}
// 4. 种子仲裁
const [arbRows]: any = await conn.query('SELECT COUNT(*) as count FROM arbitrations');
if (arbRows[0].count === 0) {
await conn.query(`
INSERT INTO arbitrations (id, order_id, order_no, client_name, companion_name, reason, user_evidence, status)
VALUES
('arb_001', 'ord_05', 'PW202609280005', '周先生', '林深见鹿', '未准时到场且提前离场', '大神迟到了35分钟,且中途以有急事为由提前40分钟离开,实际服务仅45分钟。现场有微信沟通记录与到场照片。', '待平台仲裁');
`);
}
// 5. 种子财务流水
const [finRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_records');
if (finRows[0].count === 0) {
await conn.query(`
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)
VALUES
('fin_001', 'TR20260928001', '活动报名入账', 50.00, 'IN', '用户 2001', 'act_101'),
('fin_002', 'TR20260928002', '活动报名入账', 50.00, 'IN', '用户 2002', 'act_101'),
('fin_003', 'TR20260928003', '大神订单全款', 316.00, 'IN', '客户 赵公子', 'ord_01'),
('fin_004', 'TR20260928004', '平台抽成 (10%)', 31.60, 'IN', '系统结算', 'ord_01'),
('fin_005', 'TR20260928005', '一级分销佣金 (5%)', 15.80, 'OUT', '推荐人 张三', 'ord_01'),
('fin_006', 'TR20260928006', '房主追加赏金差额退补', 300.00, 'IN', '房主 1001', 'act_101');
`);
}
// 6. 种子提现
const [wdRows]: any = await conn.query('SELECT COUNT(*) as count FROM withdrawals');
if (wdRows[0].count === 0) {
await conn.query(`
INSERT INTO withdrawals (id, withdraw_no, companion_name, companion_id, amount, balance, channel, status)
VALUES
('wd_001', 'TX20260928001', '苏苏学姐', 101, 1250.00, 4890.00, '微信商户零钱', '待审核'),
('wd_002', 'TX20260928002', '顾北辰', 104, 2800.00, 6200.00, '银联企业直连', '已打款');
`);
}
// 7. 种子场所
const [placeRows]: any = await conn.query('SELECT COUNT(*) as count FROM safety_places');
if (placeRows[0].count === 0) {
await conn.query(`
INSERT INTO safety_places (id, name, area, category, safety_level, status, approved)
VALUES
('place_001', '三里屯聚宝源铜锅涮肉', '朝阳区三里屯商圈', '餐饮美食', 'AAA级合规认证', '正常营业', 1),
('place_002', '工体纯KTV', '朝阳区工人体育场商圈', '量贩KTV', 'AAA级合规认证', '正常营业', 1),
('place_003', '望京合生麒麟社跳海酒馆', '朝阳区望京商圈', '商圈清吧', 'AA级合规认证', '正常营业', 1),
('place_004', '朝阳大悦城太二酸菜鱼', '朝阳区青年路大悦城', '餐饮美食', 'AAA级合规认证', '正常营业', 1);
`);
}
// 8. 种子风控拦截日志
const [secRows]: any = await conn.query('SELECT COUNT(*) as count FROM safety_audit_logs');
if (secRows[0].count === 0) {
await conn.query(`
INSERT INTO safety_audit_logs (id, timestamp_str, user_id, type, content, action, status)
VALUES
('sec_001', '2026-09-28 14:12:05', 2088, '违规场所拦截', '用户尝试将线下陪吃地点填写为:某某苑4号楼2单元501室(私人住宅)', '系统自动阻断下单,弹出公共商圈合规红线告警', '已处置'),
('sec_002', '2026-09-28 11:35:20', 2095, '违规场所拦截', '用户尝试将线下陪唱地点填写为:某某精品快捷酒店308客房', '系统拦截并强制要求修改为正规商业量贩KTV', '已处置'),
('sec_003', '2026-09-27 21:04:18', 2012, '敏感词过滤', '组局留言中出现敏感违规引流词汇汇款、加私微等', '关键词自动打码屏蔽,并记录账号风控提示分', '已处置');
`);
}
// 9. 种子全平台注册用户
const [userRows]: any = await conn.query('SELECT COUNT(*) as count FROM users');
if (userRows[0].count === 0) {
await conn.query(`
INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, order_count, activity_count, status, wechat_openid, created_at)
VALUES
(1001, 'U1001', '同城局长', 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120', '138****1001', '男', '同城房主', 1880.00, 8, 12, '正常', 'wx_openid_1001', '2026-09-01 10:00:00'),
(2001, 'U2001', '涮肉小行家', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '139****2001', '女', '普通玩家', 260.00, 15, 6, '正常', 'wx_openid_2001', '2026-09-05 14:20:00'),
(2002, 'U2002', '吃货小张', 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', '136****2002', '男', '普通玩家', 120.00, 6, 2, '正常', 'wx_openid_2002', '2026-09-10 11:15:00'),
(2003, 'U2003', 'K歌达人阿伟', 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=120', '137****2003', '男', '普通玩家', 350.00, 21, 9, '正常', 'wx_openid_2003', '2026-09-12 18:30:00'),
(2004, 'U2004', '电竞刺客', 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=120', '135****2004', '男', '普通玩家', 50.00, 9, 3, '正常', 'wx_openid_2004', '2026-09-15 20:00:00'),
(2005, 'U2005', '橙子小七', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '138****2005', '女', '认证大神', 4280.00, 420, 28, '正常', 'wx_openid_2005', '2026-09-02 09:00:00'),
(2006, 'U2006', '苏苏学姐', 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', '139****2006', '女', '认证大神', 5690.00, 560, 35, '正常', 'wx_openid_2006', '2026-09-03 16:45:00'),
(2007, 'U2007', '莫吉托学长', 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=120', '137****2007', '男', '认证大神', 3100.00, 310, 18, '正常', 'wx_openid_2007', '2026-09-08 12:20:00'),
(2008, 'U2008', '测试玩家小李', 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120', '188****2008', '男', '普通玩家', 88.00, 2, 1, '正常', 'wx_openid_2008', '2026-09-28 10:10:00'),
(2009, 'U2009', '风险异常号', 'https://images.unsplash.com/photo-1492562080023-ab3db95bfbce?w=120', '131****2009', '未知', '普通玩家', 0.00, 0, 0, '已封禁', 'wx_openid_2009', '2026-09-27 15:30:00');
`);
}
// 10. 种子友圈动态
const [postRows]: any = await conn.query('SELECT COUNT(*) as count FROM posts');
if (postRows[0].count === 0) {
await conn.query(`
INSERT INTO posts (id, user_id, author, avatar, tag, tag_type, location, distance, content, topics, images, action_text, likes, comments, views, category, created_at)
VALUES
('post_1', 2005, '小甜橙', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150', '火锅达人', 'warm-tag', '朝阳区 · 三里屯太古里', '800m', '今晚7点半有没有在三里屯附近的小伙伴一起去吃铜锅涮肉?已预订包间,目前已有2人,再来2个饭搭子AA!吃完还可以顺路去清吧听歌放松~', '["三里屯探店", "铜锅涮肉AA", "饭搭子招募"]', '["https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=500", "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=500"]', '加入车队', 42, 8, 310, 'partner', NOW() - INTERVAL 15 MINUTE),
('post_2', 2004, '暗影刺客 (省一李白)', 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150', '国服大神', 'cool-tag', '海淀区 · 五道口', '3.5km', '今晚王者荣耀荣耀王者冲分车队!目前缺一个听指挥的中单或者软辅妹子,全程开麦包赢不保段包赔,想上星的速速滴我!', '["王者荣耀", "荣耀车队", "连胜冲分"]', '["https://images.unsplash.com/photo-1542751371-adc38448a05e?w=500"]', '立即上车', 76, 15, 680, 'game', NOW() - INTERVAL 40 MINUTE),
('post_3', 2006, '苏苏学姐', 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150', '金牌麦霸', 'gold-tag', '朝阳区 · 工体商业街', '1.2km', '工体纯K量贩欢唱局!甜美情歌、粤语老歌、摇滚气氛拉满。现场备有精美果盘与特饮,今晚欢唱不嗨不散,还有专属歌声试听哦!', '["KTV欢唱", "量贩陪唱", "同城聚会"]', '["https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=500"]', '报名同去', 58, 11, 520, 'partner', NOW() - INTERVAL 1 HOUR);
`);
}
// 11. 种子订单真实评价
const [revRows]: any = await conn.query('SELECT COUNT(*) as count FROM order_reviews');
if (revRows[0].count === 0) {
await conn.query(`
INSERT INTO order_reviews (id, order_id, expert_id, user_id, user_nickname, user_avatar, rating, tags, content, created_at)
VALUES
('rev_101', 'ord_04', 104, 1001, '李小姐', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', 5, '["技术过硬", "耐心温柔", "连胜吃鸡"]', '北辰大神意识太好了,几波团战残血反杀,全程听指挥轻轻松松上大分!', NOW() - INTERVAL 1 DAY),
('rev_102', 'ord_01', 101, 2001, '赵公子', 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', 5, '["随和健谈", "品味极佳", "准时守约"]', '苏苏学姐推荐的涮肉店真的一绝,性格温柔幽默,聚餐氛围特别好!', NOW() - INTERVAL 2 DAY);
`);
}
// 12. 种子友圈动态评论
const [cmtRows]: any = await conn.query('SELECT COUNT(*) as count FROM post_comments');
if (cmtRows[0].count === 0) {
await conn.query(`
INSERT INTO post_comments (id, post_id, user_id, author, avatar, content, created_at)
VALUES
('cmt_1', 'post_1', 2002, '吃货小张', 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', '三里屯聚宝源吗?这家手切羊肉超棒,已报名+1!', NOW() - INTERVAL 10 MINUTE),
('cmt_2', 'post_1', 2006, '苏苏学姐', 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', '吃完可以去附近跳海酒馆坐坐,环境很舒服~', NOW() - INTERVAL 5 MINUTE),
('cmt_3', 'post_2', 2001, '涮肉小行家', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '国服李白太强了!主玩大乔和张飞,晚上可以一起开黑吗?', NOW() - INTERVAL 25 MINUTE);
`);
}
}

1448
server/src/db/repository.ts 普通文件

文件差异内容过多而无法显示 加载差异

查看文件

@@ -0,0 +1,34 @@
import mysql from 'mysql2/promise';
async function testConnection() {
const host = '118.184.172.94';
const port = 3306;
const user = 'peiwan';
const password = 'XxW7Xe5rRSckKmXm';
const database = 'peiwan';
console.log(`[Remote Test] 尝试连接远程 MySQL: ${host}:${port}, 用户: ${user}, 库: ${database}`);
try {
const connection = await mysql.createConnection({
host,
port,
user,
password,
database,
connectTimeout: 5000,
});
console.log('[Remote Test] 恭喜,成功连接到线上远程数据库!');
const [rows]: any = await connection.query('SHOW TABLES;');
console.log('[Remote Test] 线上已存在的表数量:', rows.length);
console.log('[Remote Test] 表列表:', rows.map((r: any) => Object.values(r)[0]).join(', '));
await connection.end();
return true;
} catch (err: any) {
console.error('[Remote Test] 连接失败:', err.code, err.message);
return false;
}
}
testConnection();

1037
server/src/index.ts 普通文件

文件差异内容过多而无法显示 加载差异

查看文件

@@ -90,4 +90,30 @@ describe('ActivityListContent (TDD 测试套件: 活动大厅陪吃·陪唱·陪
expect(content).toMatch(/(下午茶陪喝|陪喝)/);
});
});
describe('3. 活动大厅综合搜索框功能与双主题规范门禁 (TDD)', () => {
it('页面必须包含顶部综合搜索框组件并支持关键词双向绑定', () => {
const content = fs.readFileSync(listVuePath, 'utf-8');
expect(content).toContain('header-search');
expect(content).toContain('search-input');
expect(content).toContain('searchKeyword');
});
it('搜索过滤逻辑必须同时支持标题、商圈地点、品类与局长多维度匹配', () => {
const content = fs.readFileSync(listVuePath, 'utf-8');
// 必须包含基于 searchKeyword 的模糊匹配逻辑
expect(content).toContain('matchKeyword');
expect(content).toMatch(/a\.title.*includes/);
expect(content).toMatch(/a\.location.*includes/);
expect(content).toMatch(/a\.creatorNickname.*includes/);
});
it('搜索框在 theme-warm 浅色暖阳晨曦模式下必须适配纯白背景与深色文本', () => {
const content = fs.readFileSync(listVuePath, 'utf-8');
const warmSection = content.split('.activity-container.theme-warm')[1] || '';
expect(warmSection).toContain('.header-search');
expect(warmSection).toContain('.search-box');
expect(warmSection).toContain('#FFFFFF');
});
});
});

查看文件

@@ -192,5 +192,205 @@ describe('ActivityService (TDD 测试套件: 活动项目与4位核销码)', ()
expect(check.success).toBe(true);
});
});
describe('5. 房主手动调价与加赏加急机制 (提价/降价与已报名成员差额补偿)', () => {
it('发单500元/人招募4人,2人报名后房主手动提价至800元/人,全员同享新高价差额', () => {
// 1. 房主发起4人单,每人500元 (50000分)
const activity = activityService.createActivity({
creatorId: 1001,
title: '【高端陪玩】四排吃鸡车队高额赏金局',
gameCategory: '热门电竞',
payType: ActivityPayType.PROJECT_PAY,
perPersonFeeCents: 50000,
maxParticipants: 4,
allowDynamicPrice: true,
priceAdjustRule: 'UNIFIED_HIGH',
});
// 2. 两人报名成功
const p1 = activityService.joinActivity(activity.id, { userId: 2001, payAmountCents: 50000 });
const p2 = activityService.joinActivity(activity.id, { userId: 2002, payAmountCents: 50000 });
expect(activity.currentParticipants).toBe(2);
expect(p1.payAmountCents).toBe(50000);
expect(p2.payAmountCents).toBe(50000);
// 3. 房主提价至 800元/人 (80000分) 加急招募
const result = activityService.adjustActivityPrice({
activityId: activity.id,
operatorId: 1001,
newPriceCents: 80000,
});
expect(result.priceDiffCents).toBe(30000); // 每人加赏300元
expect(result.affectedParticipantsCount).toBe(2);
expect(result.activity.perPersonFeeCents).toBe(80000);
expect(result.activity.totalFeeCents).toBe(320000); // 800 * 4 = 3200元
expect(p1.payAmountCents).toBe(80000);
expect(p2.payAmountCents).toBe(80000);
// 4. 第3位新加入的成员直接按最新高价800元报名
const p3 = activityService.joinActivity(activity.id, { userId: 2003, payAmountCents: 80000 });
expect(p3.payAmountCents).toBe(80000);
expect(activity.currentParticipants).toBe(3);
});
it('非房主尝试调价应抛出权限异常', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '测试活动',
gameCategory: '同城陪吃',
payType: ActivityPayType.PROJECT_PAY,
perPersonFeeCents: 50000,
maxParticipants: 4,
});
expect(() => {
activityService.adjustActivityPrice({
activityId: act.id,
operatorId: 2002, // 非房主
newPriceCents: 80000,
});
}).toThrow('只有发起人有权调整活动出价');
});
});
describe('6. 房主解散与取消组局 (0人报名秒撤 / 有人报名退款全员)', () => {
it('0人报名时,房主可直接解散取消组局', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '测试酸汤火锅局',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 4,
});
const result = activityService.cancelActivity({
activityId: act.id,
operatorId: 1001,
reason: '临时有事改期',
});
expect(result.success).toBe(true);
expect(result.status).toBe('CANCELLED');
expect(result.refundedCount).toBe(0);
expect(result.totalRefundCents).toBe(0);
});
it('已有搭子报名时,房主解散将全额退还已报名成员款项', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '三里屯火锅局',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 4,
});
// 两位搭子报名付款
activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 5000 });
activityService.joinActivity(act.id, { userId: 2002, payAmountCents: 5000 });
const result = activityService.cancelActivity({
activityId: act.id,
operatorId: 1001,
reason: '突发暴雨改期',
});
expect(result.success).toBe(true);
expect(result.status).toBe('CANCELLED');
expect(result.refundedCount).toBe(2);
expect(result.totalRefundCents).toBe(10000); // 5000 * 2 = 10000分
expect(result.refundDetails).toEqual([
{ userId: 2001, refundAmountCents: 5000 },
{ userId: 2002, refundAmountCents: 5000 },
]);
});
it('非房主尝试解散组局应抛出权限异常', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '测试活动',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 4,
});
expect(() => {
activityService.cancelActivity({
activityId: act.id,
operatorId: 2005, // 非房主
});
}).toThrow('只有发起人有权解散此组局');
});
it('已核销或已完成的活动不可再被解散', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '测试活动',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 2,
});
const pt = activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 5000 });
activityService.verifyCheckin({ activityId: act.id, operatorId: 1001, verifyCode: pt.verifyCode });
expect(() => {
activityService.cancelActivity({
activityId: act.id,
operatorId: 1001,
});
}).toThrow('聚会已有成员到场核销,不可随意解散');
});
});
describe('7. 已报名成员主动退席与名额释放', () => {
it('成员在未核销前可主动退席,席位人数自动回退', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '四人拼桌火锅',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 6000,
maxParticipants: 4,
});
activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 6000 });
activityService.joinActivity(act.id, { userId: 2002, payAmountCents: 6000 });
expect(act.currentParticipants).toBe(2);
const leaveResult = activityService.leaveActivity({
activityId: act.id,
userId: 2001,
});
expect(leaveResult.success).toBe(true);
expect(leaveResult.refundAmountCents).toBe(6000);
expect(act.currentParticipants).toBe(1);
});
it('已核销到场的成员不可退席', () => {
const act = activityService.createActivity({
creatorId: 1001,
title: '四人拼桌火锅',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 6000,
maxParticipants: 4,
});
const pt = activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 6000 });
activityService.verifyCheckin({ activityId: act.id, operatorId: 1001, verifyCode: pt.verifyCode });
expect(() => {
activityService.leaveActivity({
activityId: act.id,
userId: 2001,
});
}).toThrow('已核销到场,无法申请退席');
});
});
});

查看文件

@@ -0,0 +1,90 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vm from 'vm';
function loadFrontendEsm(filePath: string) {
let code = fs.readFileSync(filePath, 'utf-8');
code = code.replace(/export\s+const\s+(\w+)/g, 'const $1');
code = code.replace(/export\s+class\s+(\w+)/g, 'class $1');
code = code.replace(/export\s+function\s+(\w+)/g, 'function $1');
const sandbox: any = { module: { exports: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
`${code}\nmodule.exports = { ...module.exports, ...(typeof encodeCode128B !== 'undefined' ? { encodeCode128B, getBarcodeBits } : {}), ...(typeof generateQRCodeMatrix !== 'undefined' ? { generateQRCodeMatrix, QRCodeModel } : {}) };`,
sandbox
);
return sandbox.module.exports;
}
describe('Barcode & QRCode Architecture (TDD 门票真实条形码与二维码生成及零Mock门禁)', () => {
const rootDir = path.resolve(__dirname, '../..');
const barcodeJsPath = path.join(rootDir, 'pwuniapp/common/barcode.js');
const qrcodeJsPath = path.join(rootDir, 'pwuniapp/common/qrcode.js');
const ticketVuePath = path.join(rootDir, 'pwuniapp/pages/activity/my-ticket.vue');
describe('1. Code128 条形码算法规范', () => {
it('barcode.js 文件必须存在且导出 encodeCode128B 和 getBarcodeBits', () => {
expect(fs.existsSync(barcodeJsPath)).toBe(true);
const content = fs.readFileSync(barcodeJsPath, 'utf-8');
expect(content).toContain('export function encodeCode128B');
expect(content).toContain('export function getBarcodeBits');
});
it('Code128 编码能够正确计算校验码并生成标准条空序列', () => {
const { encodeCode128B, getBarcodeBits } = loadFrontendEsm(barcodeJsPath);
const testCode = 'TICKET-101-8392';
const segments = encodeCode128B(testCode);
expect(Array.isArray(segments)).toBe(true);
expect(segments.length).toBeGreaterThan(10);
const bits = getBarcodeBits(testCode);
expect(typeof bits).toBe('string');
expect(bits).toMatch(/^[01]+$/);
});
});
describe('2. QR Code 二维码矩阵算法规范', () => {
it('qrcode.js 文件必须存在且导出 generateQRCodeMatrix 和 QRCodeModel', () => {
expect(fs.existsSync(qrcodeJsPath)).toBe(true);
const content = fs.readFileSync(qrcodeJsPath, 'utf-8');
expect(content).toContain('export function generateQRCodeMatrix');
expect(content).toContain('export class QRCodeModel');
});
it('QR Code 算法能够为任意门票串生成合法的 N*N 二维点阵矩阵', () => {
const { generateQRCodeMatrix } = loadFrontendEsm(qrcodeJsPath);
const testText = 'TICKET-101-8392';
const matrix = generateQRCodeMatrix(testText, 2);
expect(Array.isArray(matrix)).toBe(true);
expect(matrix.length).toBe(25); // Ver 2: 25x25 矩阵
expect(matrix[0].length).toBe(25);
// 验证寻象图形 (Position Probe Pattern: 左上角第 0 行应有黑块)
expect(matrix[0][0]).toBe(true);
});
});
describe('3. 门票页面真实接入门禁与移除Mock依赖', () => {
it('my-ticket.vue 必须引入真实条形码与二维码生成算法', () => {
const content = fs.readFileSync(ticketVuePath, 'utf-8');
expect(content).toContain('encodeCode128B');
expect(content).toContain('generateQRCodeMatrix');
expect(content).toContain('barcodeSegments');
expect(content).toContain('qrcodeMatrix');
});
it('my-ticket.vue 必须彻底移除对 ACTIVITIES_LIST 假Mock数据的依赖', () => {
const content = fs.readFileSync(ticketVuePath, 'utf-8');
expect(content).not.toContain("import { ACTIVITIES_LIST } from '@/common/mock.js';");
expect(content).not.toContain('const ticketData = ref(ACTIVITIES_LIST[0])');
});
it('所有新写的代码中严禁包含任何系统 Emoji', () => {
const bContent = fs.readFileSync(barcodeJsPath, 'utf-8');
const qContent = fs.readFileSync(qrcodeJsPath, 'utf-8');
const tContent = fs.readFileSync(ticketVuePath, 'utf-8');
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(bContent)).toBe(false);
expect(emojiRegex.test(qContent)).toBe(false);
expect(emojiRegex.test(tContent)).toBe(false);
});
});
});

查看文件

@@ -0,0 +1,712 @@
import { AuthRoleManager, UserRoleFlag, WorkMode, CertStatus } from '../src/core/auth/auth-role.manager';
import { CompanionOrderService } from '../src/core/order/companion-order.service';
import { ActivityService, ActivityPayType } from '../src/core/activity/activity.service';
import { OrderStatus } from '../src/core/order/order-state-machine';
import { CompanionTrack, ExpenseBurdenType } from '../src/core/companion/service-category';
import * as fs from 'fs';
import * as path from 'path';
describe('RBAC Permission Architecture (TDD 全流程多角色权限与越权防御套件)', () => {
let roleManager: AuthRoleManager;
let orderService: CompanionOrderService;
let activityService: ActivityService;
beforeEach(() => {
roleManager = new AuthRoleManager();
orderService = new CompanionOrderService();
activityService = new ActivityService();
});
describe('1. 四大核心角色体系与权限位图验证', () => {
it('角色位图常量定义完整且互斥', () => {
expect(UserRoleFlag.PLAYER).toBe(1);
expect(UserRoleFlag.EXPERT).toBe(2);
expect(UserRoleFlag.HOST).toBe(4);
expect(UserRoleFlag.ADMIN).toBe(8);
});
it('普通注册用户默认仅拥有玩家身份 (PLAYER)', () => {
const user = roleManager.registerUser({ openid: 'wx_user_1', nickname: '玩家小明' });
expect(user.roleFlags).toBe(UserRoleFlag.PLAYER);
expect(roleManager.isPlayer(user.id)).toBe(true);
expect(roleManager.isExpert(user.id)).toBe(false);
expect(roleManager.isHost(user.id)).toBe(false);
expect(roleManager.isAdmin(user.id)).toBe(false);
expect(user.currentMode).toBe(WorkMode.PLAYER);
});
it('平台管理员账号具备 ADMIN 与 PLAYER 复合权限', () => {
const admin = roleManager.registerAdmin({ openid: 'wx_admin_1', nickname: '总控管理员' });
expect(roleManager.isAdmin(admin.id)).toBe(true);
expect(roleManager.isPlayer(admin.id)).toBe(true);
expect(admin.roleFlags & UserRoleFlag.ADMIN).toBeTruthy();
});
it('支持动态授予与撤销角色位图权限', () => {
const user = roleManager.registerUser({ openid: 'wx_user_2', nickname: '多面手' });
expect(roleManager.isHost(user.id)).toBe(false);
// 授予车队房主 HOST 权限
roleManager.grantRole(user.id, UserRoleFlag.HOST);
expect(roleManager.isHost(user.id)).toBe(true);
// 撤销 HOST 权限
roleManager.revokeRole(user.id, UserRoleFlag.HOST);
expect(roleManager.isHost(user.id)).toBe(false);
});
});
describe('2. 角色一:普通买家/玩家 (PLAYER) 业务与越权防御', () => {
let buyer: any;
let expert: any;
let stranger: any;
beforeEach(() => {
buyer = roleManager.registerUser({ openid: 'wx_buyer', nickname: '下单老板' });
expert = roleManager.registerUser({ openid: 'wx_expert', nickname: '认证大神' });
stranger = roleManager.registerUser({ openid: 'wx_stranger', nickname: '路人用户' });
// 认证大神
roleManager.submitExpertCert(expert.id, { realName: '李大神', idCardNo: '110101199001011234' });
roleManager.approveExpertCert(expert.id);
roleManager.switchMode(expert.id, WorkMode.EXPERT);
});
it('买家正常创建并支付线上订单', () => {
const order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '王者荣耀五排上分',
hours: 2,
totalAmountCents: 6000,
gameDetails: {
gameName: '王者荣耀',
gameId: '峡谷第一野王',
serverDistrict: '微信区',
},
});
expect(order.status).toBe(OrderStatus.PENDING_PAYMENT);
// 只有买家有权付款
orderService.payOrder(order.id, buyer.id);
expect(order.status).toBe(OrderStatus.PENDING_ACCEPT);
});
it('越权拦截:非下单买家尝试支付订单应被拒绝', () => {
const order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '王者双排',
hours: 1,
totalAmountCents: 3000,
});
expect(() => {
orderService.payOrder(order.id, stranger.id);
}).toThrow('非下单买家无权支付该订单');
});
it('待付款状态下买家可取消订单,非买家取消应被拒绝', () => {
const order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '王者双排',
hours: 1,
totalAmountCents: 3000,
});
// 非买家尝试取消
expect(() => {
orderService.buyerCancel(order.id, stranger.id);
}).toThrow('只有下单买家有权取消订单');
// 买家正常取消
orderService.buyerCancel(order.id, buyer.id);
expect(order.status).toBe(OrderStatus.CANCELLED);
});
it('待确认状态下买家正常确认完成验收,非买家验收应被拒绝', () => {
const order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '排位服务',
hours: 1,
totalAmountCents: 3000,
});
orderService.payOrder(order.id, buyer.id);
orderService.expertAccept(order.id, expert.id, roleManager);
orderService.expertApplyFinish(order.id, expert.id);
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM);
// 非买家尝试确认验收
expect(() => {
orderService.buyerConfirm(order.id, stranger.id);
}).toThrow('只有下单买家有权确认完成验收');
// 买家确认完成
orderService.buyerConfirm(order.id, buyer.id);
expect(order.status).toBe(OrderStatus.COMPLETED);
});
it('待确认状态下买家发起争议仲裁,非买家发起仲裁应被拒绝', () => {
const order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '排位服务',
hours: 1,
totalAmountCents: 3000,
});
orderService.payOrder(order.id, buyer.id);
orderService.expertAccept(order.id, expert.id, roleManager);
orderService.expertApplyFinish(order.id, expert.id);
// 非买家尝试发起争议
expect(() => {
orderService.buyerDispute(order.id, stranger.id, '大神挂机');
}).toThrow('只有下单买家有权发起争议仲裁');
// 买家发起争议
orderService.buyerDispute(order.id, buyer.id, '大神迟到并且对局中挂机');
expect(order.status).toBe(OrderStatus.DISPUTED);
expect(order.disputeReason).toBe('大神迟到并且对局中挂机');
});
it('越权拦截:买家不可越权执行平台管理员争议仲裁', () => {
const order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '排位服务',
hours: 1,
totalAmountCents: 3000,
});
orderService.payOrder(order.id, buyer.id);
orderService.expertAccept(order.id, expert.id, roleManager);
orderService.expertApplyFinish(order.id, expert.id);
orderService.buyerDispute(order.id, buyer.id, '要求全额退款');
// 买家尝试越权自行仲裁退款
expect(() => {
orderService.arbitrate({
orderId: order.id,
operatorAdminId: buyer.id,
decision: 'REFUND',
roleManager,
});
}).toThrow();
});
});
describe('3. 角色二:陪玩大神/陪伴师 (EXPERT) 认证全流程与越权防御', () => {
let applicant: any;
let normalBuyer: any;
let admin: any;
beforeEach(() => {
applicant = roleManager.registerUser({ openid: 'wx_app_1', nickname: '申请大神选手' });
normalBuyer = roleManager.registerUser({ openid: 'wx_buy_2', nickname: '买家阿强' });
admin = roleManager.registerAdmin({ openid: 'wx_admin_2', nickname: '平台运营审核员' });
});
it('申请实名认证成功并生成 PENDING 记录', () => {
const cert = roleManager.submitExpertCert(applicant.id, {
realName: '王小萌',
idCardNo: '110101200001011111',
});
expect(cert.status).toBe(CertStatus.PENDING);
expect(roleManager.getCertStatus(applicant.id)).toBe(CertStatus.PENDING);
expect(roleManager.isExpert(applicant.id)).toBe(false);
});
it('越权拦截:普通买家尝试审核大神认证申请应被抛出权限异常拒绝', () => {
roleManager.submitExpertCert(applicant.id, {
realName: '王小萌',
idCardNo: '110101200001011111',
});
expect(() => {
roleManager.approveExpertCert(applicant.id, normalBuyer.id);
}).toThrow('只有平台管理员有权审核大神认证申请');
expect(() => {
roleManager.rejectExpertCert(applicant.id, '资料不合格', normalBuyer.id);
}).toThrow('只有平台管理员有权审核大神认证申请');
});
it('平台管理员成功审核通过实名认证,角色位图升级并支持接单', () => {
roleManager.submitExpertCert(applicant.id, {
realName: '王小萌',
idCardNo: '110101200001011111',
});
const cert = roleManager.approveExpertCert(applicant.id, admin.id);
expect(cert.status).toBe(CertStatus.APPROVED);
expect(cert.auditedBy).toBe(admin.id);
expect(roleManager.isExpert(applicant.id)).toBe(true);
// 允许切换到接单模式
roleManager.switchMode(applicant.id, WorkMode.EXPERT);
expect(roleManager.getUser(applicant.id).currentMode).toBe(WorkMode.EXPERT);
});
it('平台管理员驳回实名认证并记录驳回理由', () => {
roleManager.submitExpertCert(applicant.id, {
realName: '王小萌',
idCardNo: '110101200001011111',
});
const cert = roleManager.rejectExpertCert(applicant.id, '身份证照片模糊不清晰', admin.id);
expect(cert.status).toBe(CertStatus.REJECTED);
expect(cert.rejectReason).toBe('身份证照片模糊不清晰');
expect(roleManager.isExpert(applicant.id)).toBe(false);
});
it('越权拦截:未认证用户尝试接单应被严格拦截拒绝', () => {
const order = orderService.createOrder({
buyerId: normalBuyer.id,
expertId: applicant.id, // 尚未认证
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '游戏陪玩',
hours: 1,
totalAmountCents: 3500,
});
orderService.payOrder(order.id, normalBuyer.id);
expect(() => {
orderService.expertAccept(order.id, applicant.id, roleManager);
}).toThrow('未认证大神无法接单');
});
it('同城线下订单生成 4 位现场核销码,非接单大神核销被拒绝,接单大神现场核销闭环', () => {
// 认证大神
roleManager.submitExpertCert(applicant.id, { realName: '王小萌', idCardNo: '110101200001011111' });
roleManager.approveExpertCert(applicant.id, admin.id);
const order = orderService.createOrder({
buyerId: normalBuyer.id,
expertId: applicant.id,
track: CompanionTrack.OFFLINE_MEAL,
serviceTitle: '三里屯火锅探店',
hours: 2,
totalAmountCents: 12000,
offlineDetails: {
city: '北京市',
venueName: '朝阳大悦城海底捞',
meetTime: '今晚 19:30',
expenseBurden: ExpenseBurdenType.AA_SHARE,
},
});
orderService.payOrder(order.id, normalBuyer.id);
orderService.expertAccept(order.id, applicant.id, roleManager);
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM);
expect(order.offlineVerifyCode).toMatch(/^\d{4}$/);
// 非接单大神越权核销
expect(() => {
orderService.verifyOfflineCheckin({
orderId: order.id,
operatorExpertId: 9999, // 外部非法大神
verifyCode: order.offlineVerifyCode!,
});
}).toThrow('无权核销该订单');
// 接单大神凭正确 4 位码核销成功进入服务中
const checkinRes = orderService.verifyOfflineCheckin({
orderId: order.id,
operatorExpertId: applicant.id,
verifyCode: order.offlineVerifyCode!,
});
expect(checkinRes.success).toBe(true);
expect(order.status).toBe(OrderStatus.IN_SERVICE);
// 防重校验:不可重复核销
expect(() => {
orderService.verifyOfflineCheckin({
orderId: order.id,
operatorExpertId: applicant.id,
verifyCode: order.offlineVerifyCode!,
});
}).toThrow('该订单已完成现场核销签到');
});
it('接单大神拒单流转为已退款,非指定接单大神拒单应被拦截', () => {
roleManager.submitExpertCert(applicant.id, { realName: '王小萌', idCardNo: '110101200001011111' });
roleManager.approveExpertCert(applicant.id, admin.id);
const order = orderService.createOrder({
buyerId: normalBuyer.id,
expertId: applicant.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '游戏陪练',
hours: 1,
totalAmountCents: 3500,
});
orderService.payOrder(order.id, normalBuyer.id);
// 非指定接单大神尝试拒单
expect(() => {
orderService.expertReject(order.id, 9999);
}).toThrow('只有指定接单大神有权拒单');
// 指定接单大神拒单
orderService.expertReject(order.id, applicant.id, '时间冲突');
expect(order.status).toBe(OrderStatus.REFUNDED);
});
it('越权拦截:大神不可越权执行平台管理员争议仲裁', () => {
roleManager.submitExpertCert(applicant.id, { realName: '王小萌', idCardNo: '110101200001011111' });
roleManager.approveExpertCert(applicant.id, admin.id);
const order = orderService.createOrder({
buyerId: normalBuyer.id,
expertId: applicant.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '游戏陪练',
hours: 1,
totalAmountCents: 3500,
});
orderService.payOrder(order.id, normalBuyer.id);
orderService.expertAccept(order.id, applicant.id, roleManager);
orderService.expertApplyFinish(order.id, applicant.id);
orderService.buyerDispute(order.id, normalBuyer.id, '未达段位');
// 大神尝试越权自行结单打款
expect(() => {
orderService.arbitrate({
orderId: order.id,
operatorAdminId: applicant.id,
decision: 'RELEASE',
roleManager,
});
}).toThrow();
});
});
describe('4. 角色三:车队房主/局长 (HOST) 权限闭环与越权防御', () => {
let host: any;
let participant1: any;
let participant2: any;
let outsider: any;
let admin: any;
beforeEach(() => {
host = roleManager.registerUser({ openid: 'wx_host', nickname: '局长房主' });
participant1 = roleManager.registerUser({ openid: 'wx_p1', nickname: '搭子甲' });
participant2 = roleManager.registerUser({ openid: 'wx_p2', nickname: '搭子乙' });
outsider = roleManager.registerUser({ openid: 'wx_out', nickname: '无关人员' });
admin = roleManager.registerAdmin({ openid: 'wx_admin_3', nickname: '监管运营' });
});
it('房主发起组局自动获得 HOST 权限', () => {
const act = activityService.createActivity({
creatorId: host.id,
title: '【周末同城】火锅探店4人局',
gameCategory: '同城陪吃',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 4,
roleManager,
});
expect(act.creatorId).toBe(host.id);
expect(roleManager.isHost(host.id)).toBe(true);
});
it('房主动态调价加赏差额全员补齐,非房主调价抛出权限异常', () => {
const act = activityService.createActivity({
creatorId: host.id,
title: '【加赏急招】电竞开黑车队',
gameCategory: '热门电竞',
payType: ActivityPayType.PROJECT_PAY,
perPersonFeeCents: 5000,
maxParticipants: 3,
roleManager,
});
const p1 = activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 5000 });
// 非房主尝试调价
expect(() => {
activityService.adjustActivityPrice({
activityId: act.id,
operatorId: outsider.id,
newPriceCents: 8000,
});
}).toThrow('只有发起人有权调整活动出价');
// 房主成功调价至80元/人
const res = activityService.adjustActivityPrice({
activityId: act.id,
operatorId: host.id,
newPriceCents: 8000,
});
expect(res.priceDiffCents).toBe(3000);
expect(p1.payAmountCents).toBe(8000);
});
it('房主使用 4 位核销码核销到场搭子,非房主核销被拦截拒绝', () => {
const act = activityService.createActivity({
creatorId: host.id,
title: '同城剧本杀局',
gameCategory: '同城陪玩',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 6000,
maxParticipants: 4,
roleManager,
});
const p = activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 6000 });
// 非房主越权核销搭子门票
expect(() => {
activityService.verifyCheckin({
activityId: act.id,
operatorId: outsider.id,
verifyCode: p.verifyCode,
});
}).toThrow('无权核销该活动的核销码');
// 房主核销成功
const checkRes = activityService.verifyCheckin({
activityId: act.id,
operatorId: host.id,
verifyCode: p.verifyCode,
});
expect(checkRes.success).toBe(true);
expect(checkRes.participant.checkinStatus).toBe(true);
});
it('房主解散组局并全额原路退款已报名搭子,非房主解散被拦截', () => {
const act = activityService.createActivity({
creatorId: host.id,
title: '同城KTV狂欢局',
gameCategory: '同城陪唱',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 7500,
maxParticipants: 4,
roleManager,
});
activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 7500 });
activityService.joinActivity(act.id, { userId: participant2.id, payAmountCents: 7500 });
// 非房主尝试解散
expect(() => {
activityService.cancelActivity({
activityId: act.id,
operatorId: outsider.id,
reason: '恶意解散',
});
}).toThrow('只有发起人有权解散此组局');
// 房主正常解散
const cancelRes = activityService.cancelActivity({
activityId: act.id,
operatorId: host.id,
reason: '突发暴雨改期',
});
expect(cancelRes.success).toBe(true);
expect(cancelRes.status).toBe('CANCELLED');
expect(cancelRes.refundedCount).toBe(2);
expect(cancelRes.totalRefundCents).toBe(15000);
});
it('平台管理员具备全平台活动监管强制解散权限', () => {
const act = activityService.createActivity({
creatorId: host.id,
title: '涉嫌违规商圈组局',
gameCategory: '同城陪喝',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 2,
roleManager,
});
activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 5000 });
// 非管理员调用强制解散被拒绝
expect(() => {
activityService.adminForceCancelActivity({
activityId: act.id,
operatorAdminId: outsider.id,
reason: '违规封禁',
roleManager,
});
}).toThrow('只有平台管理员有权执行平台强制解散');
// 平台管理员执行强制解散并全额退款
const adminCancelRes = activityService.adminForceCancelActivity({
activityId: act.id,
operatorAdminId: admin.id,
reason: '违规场所管控下架',
roleManager,
});
expect(adminCancelRes.success).toBe(true);
expect(adminCancelRes.refundedCount).toBe(1);
});
});
describe('5. 角色四:平台管理员/运营端 (ADMIN) 仲裁与全流程监管', () => {
let buyer: any;
let expert: any;
let admin: any;
let order: any;
beforeEach(() => {
buyer = roleManager.registerUser({ openid: 'wx_b_arb', nickname: '申诉买家' });
expert = roleManager.registerUser({ openid: 'wx_e_arb', nickname: '涉事大神' });
admin = roleManager.registerAdmin({ openid: 'wx_a_arb', nickname: '总台仲裁法官' });
roleManager.submitExpertCert(expert.id, { realName: '赵选手', idCardNo: '110101199501018888' });
roleManager.approveExpertCert(expert.id, admin.id);
order = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '王者双排冲国服',
hours: 3,
totalAmountCents: 10500,
});
orderService.payOrder(order.id, buyer.id);
orderService.expertAccept(order.id, expert.id, roleManager);
orderService.expertApplyFinish(order.id, expert.id);
orderService.buyerDispute(order.id, buyer.id, '大神接单后擅自挂机且言语态度恶劣,申请全额退款');
});
it('管理员判定买家胜诉全额退款 (REFUND),订单流转为 REFUNDED', () => {
expect(order.status).toBe(OrderStatus.DISPUTED);
const arbRes = orderService.arbitrate({
orderId: order.id,
operatorAdminId: admin.id,
decision: 'REFUND',
reason: '查证大神确实严重违规挂机,判定全额退款买家并记录信用扣分',
roleManager,
});
expect(arbRes.status).toBe(OrderStatus.REFUNDED);
expect(arbRes.arbitrationDecision).toBe('REFUND');
expect(arbRes.arbitratedBy).toBe(admin.id);
expect(arbRes.arbitratedAt).toBeDefined();
});
it('管理员判定申诉不成立驳回 (RELEASE),订单流转为 COMPLETED', () => {
expect(order.status).toBe(OrderStatus.DISPUTED);
const arbRes = orderService.arbitrate({
orderId: order.id,
operatorAdminId: admin.id,
decision: 'RELEASE',
reason: '核实对局战绩完整履约且达成胜率指标,驳回申诉并释放服务报酬',
roleManager,
});
expect(arbRes.status).toBe(OrderStatus.COMPLETED);
expect(arbRes.arbitrationDecision).toBe('RELEASE');
expect(arbRes.arbitratedBy).toBe(admin.id);
});
it('越权拦截:非平台管理员尝试执行仲裁判定被抛出异常拒绝', () => {
const impostor = roleManager.registerUser({ openid: 'wx_impostor', nickname: '假冒管理员' });
expect(() => {
orderService.arbitrate({
orderId: order.id,
operatorAdminId: impostor.id,
decision: 'REFUND',
roleManager,
});
}).toThrow('只有平台管理员有权执行争议仲裁');
});
it('非 DISPUTED 状态的订单不可执行仲裁判定', () => {
const normalOrder = orderService.createOrder({
buyerId: buyer.id,
expertId: expert.id,
track: CompanionTrack.ONLINE_GAME,
serviceTitle: '普通对局',
hours: 1,
totalAmountCents: 3500,
});
expect(() => {
orderService.arbitrate({
orderId: normalOrder.id,
operatorAdminId: admin.id,
decision: 'REFUND',
roleManager,
});
}).toThrow('非争议仲裁中,不可执行仲裁');
});
});
describe('6. 前端 UniApp 双身份与角色守卫 (Role Guard) 规则门禁验证', () => {
const roleJsPath = path.resolve(__dirname, '../../pwuniapp/common/role.js');
const roleJsContent = fs.readFileSync(roleJsPath, 'utf-8');
it('role.js 必须定义四大核心角色 (PLAYER, EXPERT, HOST, ADMIN)', () => {
expect(roleJsContent).toContain("PLAYER: 'PLAYER'");
expect(roleJsContent).toContain("EXPERT: 'EXPERT'");
expect(roleJsContent).toContain("HOST: 'HOST'");
expect(roleJsContent).toContain("ADMIN: 'ADMIN'");
});
it('role.js 必须包含完整的认证状态机定义 (CERT_STATUS)', () => {
expect(roleJsContent).toContain('NONE: 0');
expect(roleJsContent).toContain('PENDING: 1');
expect(roleJsContent).toContain('APPROVED: 2');
expect(roleJsContent).toContain('REJECTED: 3');
});
it('role.js 必须配置非大神禁止访问的受保护页面清单与 checkRoleGuard 守卫拦截', () => {
expect(roleJsContent).toContain("'/pages/expert/workspace'");
expect(roleJsContent).toContain("'/pages/expert/radar'");
expect(roleJsContent).toContain("'/pages/expert/hall'");
expect(roleJsContent).toContain('function checkRoleGuard');
expect(roleJsContent).toContain('function isExpertCertified');
expect(roleJsContent).toContain('function installRoleGuard');
});
it('App.vue 必须在 onLaunch 中注册 installRoleGuard 全局路由守卫', () => {
const appVuePath = path.resolve(__dirname, '../../pwuniapp/App.vue');
const appVueContent = fs.readFileSync(appVuePath, 'utf-8');
expect(appVueContent).toContain('installRoleGuard');
expect(appVueContent).toContain('installRoleGuard()');
});
it('CustomTabBar 底部导航栏必须对未认证非大神访问接单端进行权限拦截', () => {
const tabbarPath = path.resolve(__dirname, '../../pwuniapp/components/CustomTabBar.vue');
const tabbarContent = fs.readFileSync(tabbarPath, 'utf-8');
expect(tabbarContent).toContain('isExpertCertified');
});
it('三大专家页面必须在 onShow 生命周期中配置兜底权限重定向防穿透', () => {
const expertPages = [
path.resolve(__dirname, '../../pwuniapp/pages/expert/workspace.vue'),
path.resolve(__dirname, '../../pwuniapp/pages/expert/radar.vue'),
path.resolve(__dirname, '../../pwuniapp/pages/expert/hall.vue'),
];
for (const p of expertPages) {
const content = fs.readFileSync(p, 'utf-8');
expect(content).toContain('isExpertCertified');
expect(content).toContain('/pages/user/index');
}
});
});
});

查看文件

@@ -0,0 +1,158 @@
import { ReviewAndCommentService } from '../src/core/review/review.service';
import { OrderStatus } from '../src/core/order/order-state-machine';
describe('ReviewAndCommentService (TDD 订单评价打分与动态评论测试套件)', () => {
let service: ReviewAndCommentService;
beforeEach(() => {
service = new ReviewAndCommentService();
});
describe('1. 订单评价业务规则与越权拦截', () => {
it('已完成(COMPLETED)状态的订单成功提交 5 星好评与评价标签', () => {
const review = service.submitOrderReview({
orderId: 'ord_1001',
orderStatus: OrderStatus.COMPLETED,
expertId: 101,
userId: 2001,
userNickname: '电竞小王子',
rating: 5,
tags: ['技术强', '声音好听', '带飞吃鸡'],
content: '大神非常给力,连续吃鸡3把!'
});
expect(review.id).toBeDefined();
expect(review.rating).toBe(5);
expect(review.tags).toContain('带飞吃鸡');
expect(review.orderId).toBe('ord_1001');
});
it('非 COMPLETED 状态的订单(如服务中/待付款)尝试评价应被拒绝拦截', () => {
expect(() => {
service.submitOrderReview({
orderId: 'ord_1002',
orderStatus: OrderStatus.IN_SERVICE,
expertId: 101,
userId: 2001,
userNickname: '小李',
rating: 5,
});
}).toThrow('只有已完成(COMPLETED)状态的订单才允许提交服务评价');
});
it('评分小于 1 或大于 5 时应抛出参数校验异常', () => {
expect(() => {
service.submitOrderReview({
orderId: 'ord_1003',
orderStatus: OrderStatus.COMPLETED,
expertId: 101,
userId: 2001,
userNickname: '小李',
rating: 6,
});
}).toThrow('评分必须在 1 至 5 星之间');
expect(() => {
service.submitOrderReview({
orderId: 'ord_1004',
orderStatus: OrderStatus.COMPLETED,
expertId: 101,
userId: 2001,
userNickname: '小李',
rating: 0,
});
}).toThrow('评分必须在 1 至 5 星之间');
});
it('同一订单禁止重复提交评价(防刷评)', () => {
service.submitOrderReview({
orderId: 'ord_1005',
orderStatus: OrderStatus.COMPLETED,
expertId: 101,
userId: 2001,
userNickname: '小李',
rating: 5,
});
expect(() => {
service.submitOrderReview({
orderId: 'ord_1005',
orderStatus: OrderStatus.COMPLETED,
expertId: 101,
userId: 2001,
userNickname: '小李',
rating: 4,
});
}).toThrow('该订单已评价,不可重复提交评价');
});
it('大神新增多条评价后,动态综合平均评分计算准确', () => {
service.submitOrderReview({
orderId: 'ord_201',
orderStatus: OrderStatus.COMPLETED,
expertId: 102,
userId: 301,
userNickname: '玩家A',
rating: 5,
});
service.submitOrderReview({
orderId: 'ord_202',
orderStatus: OrderStatus.COMPLETED,
expertId: 102,
userId: 302,
userNickname: '玩家B',
rating: 4,
});
const avg = service.calculateExpertAverageRating(102);
expect(avg).toBe(4.5);
});
});
describe('2. 友圈动态互动评论区规则', () => {
it('成功发表动态互动评论并支持按动态聚合查询', () => {
const c1 = service.submitPostComment({
postId: 'post_001',
userId: 2001,
author: '三里屯吃货',
content: '我也在三里屯,求组队!'
});
const c2 = service.submitPostComment({
postId: 'post_001',
userId: 2002,
author: '火锅达人',
content: '晚上几点开饭?已加微。'
});
const list = service.getCommentsByPost('post_001');
expect(list.length).toBe(2);
expect(list[0].id).toBe(c1.id);
expect(list[1].id).toBe(c2.id);
});
it('空评论或纯空格评论应被拦截', () => {
expect(() => {
service.submitPostComment({
postId: 'post_001',
userId: 2001,
author: '张三',
content: ' '
});
}).toThrow('评论内容不能为空');
});
it('超长评论(超过500字)应被安全阻断', () => {
const longText = 'a'.repeat(501);
expect(() => {
service.submitPostComment({
postId: 'post_001',
userId: 2001,
author: '张三',
content: longText
});
}).toThrow('评论内容不能超过 500 个字符');
});
});
});

查看文件

@@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
describe('SkeletonScreen Architecture (TDD 骨架屏流光扫描与首屏加载体验门禁)', () => {
const rootDir = path.resolve(__dirname, '../../');
const skeletonComponentPath = path.join(rootDir, 'pwuniapp/components/SkeletonScreen.vue');
const pagePaths = {
index: path.join(rootDir, 'pwuniapp/pages/index/index.vue'),
activity: path.join(rootDir, 'pwuniapp/pages/activity/list.vue'),
community: path.join(rootDir, 'pwuniapp/pages/community/index.vue'),
expert: path.join(rootDir, 'pwuniapp/pages/expert/detail.vue')
};
describe('1. 骨架屏公共组件规范性验证 (SkeletonScreen.vue)', () => {
it('SkeletonScreen.vue 文件必须存在且格式有效', () => {
expect(fs.existsSync(skeletonComponentPath)).toBe(true);
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
expect(content.length).toBeGreaterThan(100);
});
it('必须支持四大核心业务形态:home、activity、community、expert', () => {
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
expect(content).toContain("type === 'home'");
expect(content).toContain("type === 'activity'");
expect(content).toContain("type === 'community'");
expect(content).toContain("type === 'expert'");
});
it('必须包含纯 CSS 骨架扫光动画与流光渐变关键帧', () => {
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
expect(content).toContain('skeletonShimmer');
expect(content).toContain('@keyframes skeletonShimmer');
expect(content).toContain('skeleton-shimmer');
});
it('必须完整支持双主题系统 (暗夜冷光与 theme-warm 浅色暖光)', () => {
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
expect(content).toContain('themeClass');
expect(content).toContain('.theme-warm');
});
it('骨架屏组件必须严格遵循零 Emoji 门禁规范', () => {
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(content)).toBe(false);
});
});
describe('2. 四大核心业务页面骨架屏接入门禁', () => {
it('首页 (index.vue) 必须引入 SkeletonScreen 并配置 home 骨架屏与 isLoading 状态机', () => {
const content = fs.readFileSync(pagePaths.index, 'utf-8');
expect(content).toContain('SkeletonScreen');
expect(content).toContain('<skeleton-screen');
expect(content).toContain('type="home"');
expect(content).toContain('isLoading');
});
it('活动大厅 (activity/list.vue) 必须引入 SkeletonScreen 并配置 activity 骨架屏与 isLoading 状态机', () => {
const content = fs.readFileSync(pagePaths.activity, 'utf-8');
expect(content).toContain('SkeletonScreen');
expect(content).toContain('<skeleton-screen');
expect(content).toContain('type="activity"');
expect(content).toContain('isLoading');
});
it('友圈广场 (community/index.vue) 必须引入 SkeletonScreen 并配置 community 骨架屏与 isLoading 状态机', () => {
const content = fs.readFileSync(pagePaths.community, 'utf-8');
expect(content).toContain('SkeletonScreen');
expect(content).toContain('<skeleton-screen');
expect(content).toContain('type="community"');
expect(content).toContain('isLoading');
});
it('大神主页 (expert/detail.vue) 必须引入 SkeletonScreen 并配置 expert 骨架屏与 isLoading 状态机', () => {
const content = fs.readFileSync(pagePaths.expert, 'utf-8');
expect(content).toContain('SkeletonScreen');
expect(content).toContain('<skeleton-screen');
expect(content).toContain('type="expert"');
expect(content).toContain('isLoading');
});
});
});

查看文件

@@ -393,6 +393,42 @@ describe('ThemeManager (TDD 测试套件: 暖色系与双主题设计系统)', (
expect(content.includes('capsule-space') || content.includes('MP-WEIXIN')).toBe(true);
});
});
describe('11. 底部自定义导航栏 (CustomTabBar) 与多端安全区门禁校验', () => {
const rootDir = path.resolve(__dirname, '../../');
const customTabBarPath = path.join(rootDir, 'pwuniapp', 'components', 'CustomTabBar.vue');
it('CustomTabBar 容器高度必须动态叠加 safe-area-inset-bottom,防止全面屏黑条遮挡截断', () => {
const content = fs.readFileSync(customTabBarPath, 'utf-8');
expect(content).toContain('env(safe-area-inset-bottom)');
expect(content).toMatch(/height:\s*calc\(118rpx\s*\+\s*env\(safe-area-inset-bottom\)\)/);
expect(content).toContain('height: 118rpx');
});
});
describe('12. 顶部胶囊与灵动岛状态栏像素级对齐门禁校验', () => {
const rootDir = path.resolve(__dirname, '../../');
const navJsPath = path.join(rootDir, 'pwuniapp', 'common', 'nav.js');
const indexVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue');
const activityVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'list.vue');
it('nav.js 必须具备动态解析状态栏与微信胶囊位置的能力', () => {
expect(fs.existsSync(navJsPath)).toBe(true);
const content = fs.readFileSync(navJsPath, 'utf-8');
expect(content).toContain('getMenuButtonBoundingClientRect');
expect(content).toContain('statusBarHeight');
expect(content).toContain('capsuleRightSpace');
});
it('核心搜索页面必须动态绑定 navStyle 并在小程序端对齐胶囊高度', () => {
const indexContent = fs.readFileSync(indexVuePath, 'utf-8');
const actContent = fs.readFileSync(activityVuePath, 'utf-8');
expect(indexContent).toContain('getNavBarInfo');
expect(indexContent).toContain(':style="navStyle"');
expect(actContent).toContain('getNavBarInfo');
expect(actContent).toContain(':style="navStyle"');
});
});
});

33
server/tests/upload.spec.ts 普通文件
查看文件

@@ -0,0 +1,33 @@
import * as fs from 'fs';
import * as path from 'path';
describe('FileUpload Service (TDD 文件上传与静态对象存储服务门禁)', () => {
const rootDir = path.resolve(__dirname, '../..');
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
const uploadsDirPath = path.join(rootDir, 'server/public/uploads');
it('静态文件上传持久化目录 public/uploads 必须存在', () => {
expect(fs.existsSync(uploadsDirPath)).toBe(true);
});
it('server/src/index.ts 必须挂载 /uploads 静态托管路由与 /api/upload 接口', () => {
const content = fs.readFileSync(serverIndexPath, 'utf-8');
expect(content).toContain("app.use('/uploads', express.static(uploadDir));");
expect(content).toContain("app.post('/api/upload', handleFileUpload);");
expect(content).toContain("app.post('/api/user/upload', handleFileUpload);");
});
it('文件上传配置必须限制 10MB 与合法图片格式过滤', () => {
const content = fs.readFileSync(serverIndexPath, 'utf-8');
expect(content).toContain('10 * 1024 * 1024');
expect(content).toContain('image/jpeg');
expect(content).toContain('image/png');
expect(content).toContain('image/webp');
});
it('文件上传逻辑代码中必须严格遵循零 Emoji 门禁规范', () => {
const content = fs.readFileSync(serverIndexPath, 'utf-8');
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(content)).toBe(false);
});
});