feat: 完善对象存储上传与真实条码二维码引擎,彻底剥离保底Mock数据
这个提交包含在:
@@ -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
普通文件
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
普通文件
1448
server/src/db/repository.ts
普通文件
文件差异内容过多而无法显示
加载差异
34
server/src/db/test-remote.ts
普通文件
34
server/src/db/test-remote.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
普通文件
1037
server/src/index.ts
普通文件
文件差异内容过多而无法显示
加载差异
在新工单中引用
屏蔽一个用户