feat(spec-3): complete section 3.1/3.2/3.3 activity verify settlement, role_flags, and distribution_relations

这个提交包含在:
xcbyy
2026-10-14 11:41:03 +08:00
父节点 8fc4d601c3
当前提交 a14f27d641
共有 5 个文件被更改,包括 255 次插入 和 17 次删除

查看文件

@@ -458,12 +458,16 @@ export async function initDatabase(): Promise<void> {
try {
await conn.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0');
} catch (_) {}
try {
await conn.query('ALTER TABLE users ADD COLUMN role_flags TINYINT DEFAULT 1');
await conn.query('UPDATE users SET role_flags = 3 WHERE is_expert = 1 OR role = "认证大神"');
} catch (_) {}
// 确保存在 demo 账号 101 (金牌陪伴师/认证大神体验账号)
try {
await conn.query(`
INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, commission, invited_count, order_count, activity_count, status, wechat_openid, signature, is_expert)
VALUES (101, 'U101', '糖心小糯米', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', '138****0101', '女', '认证大神', 2580.00, 128.50, 6, 88, 16, '正常', 'wx_openid_101', '人美声甜,擅长各类同城陪伴与开黑互动', 1)
ON DUPLICATE KEY UPDATE nickname = VALUES(nickname), is_expert = 1
INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, commission, invited_count, order_count, activity_count, status, wechat_openid, signature, is_expert, role_flags)
VALUES (101, 'U101', '糖心小糯米', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', '138****0101', '女', '认证大神', 2580.00, 128.50, 6, 88, 16, '正常', 'wx_openid_101', '人美声甜,擅长各类同城陪伴与开黑互动', 1, 3)
ON DUPLICATE KEY UPDATE nickname = VALUES(nickname), is_expert = 1, role_flags = 3
`);
} catch (_) {}
@@ -517,11 +521,24 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 23. 分销体系扩展 (下级团队与佣金订单记录)
// 23. 分销体系扩展 (父子终身绑定关系表、下级团队与佣金订单记录)
try {
await conn.query('ALTER TABLE users ADD COLUMN inviter_id INT DEFAULT NULL');
} catch (_) {}
await conn.query(`
CREATE TABLE IF NOT EXISTS distribution_relations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT UNIQUE NOT NULL,
inviter_id INT NOT NULL,
level2_inviter_id INT DEFAULT NULL,
bind_source VARCHAR(64) DEFAULT 'INVITE_CODE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_inviter (inviter_id),
INDEX idx_level2_inviter (level2_inviter_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
await conn.query(`
CREATE TABLE IF NOT EXISTS distribution_records (
id VARCHAR(64) PRIMARY KEY,

查看文件

@@ -120,6 +120,84 @@ export class MysqlRepository {
return this.getActivityById(id);
}
private verifyCodeCache = new Map<string, { participantId: string; userId: number; expireAt: number }>();
private async settleActivityCheckinAndReferral(
activityId: string,
creatorId: number,
participantId: string,
participantUserId: number,
payAmountCents: number
): Promise<void> {
const feeYuan = Number((Number(payAmountCents || 0) / 100).toFixed(2));
if (feeYuan <= 0) return;
try {
const ts = Date.now();
const rand = () => Math.floor(100 + Math.random() * 900);
// 1. 触发活动费用清算逻辑:将核销款项划入活动发起人账户并记录清算流水
if (creatorId > 0) {
await pool.query(
'UPDATE users SET balance = COALESCE(balance, 0) + ?, activity_count = COALESCE(activity_count, 0) + 1 WHERE id = ?',
[feeYuan, creatorId]
);
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '活动核销费用清算', ?, 'IN', ?, ?, NOW())`,
[
`fin_act_${ts}_${rand()}`,
`AC${ts}${rand()}`,
creatorId,
feeYuan,
`参与搭子 ${participantUserId} (凭证 ${participantId})`,
activityId
]
);
}
// 2. 付费活动完成后触发一级(5%) / 二级(2%)分销返佣
const cfg = await this.getFinanceConfig();
const firstRefPct = Number(cfg.firstReferralPercent ?? 5);
const secondRefPct = Number(cfg.secondReferralPercent ?? 2);
const [buyerRows]: any = await pool.query(
'SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1',
[participantUserId]
);
const buyer = buyerRows && buyerRows[0];
const fromUserName = buyer?.nickname || `搭子_${participantUserId}`;
if (buyer && buyer.inviter_id && firstRefPct > 0) {
const lv1UserId = Number(buyer.inviter_id);
const comm1 = Number(((feeYuan * firstRefPct) / 100).toFixed(2));
if (comm1 > 0 && lv1UserId > 0 && lv1UserId !== participantUserId) {
await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm1, lv1UserId]);
await pool.query(
`INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, '已入账', NOW())`,
[`dst_act1_${ts}_${rand()}`, lv1UserId, participantUserId, fromUserName, activityId, feeYuan, comm1, firstRefPct]
);
}
const [lv1Rows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [lv1UserId]);
const lv1User = lv1Rows && lv1Rows[0];
if (lv1User && lv1User.inviter_id && secondRefPct > 0) {
const lv2UserId = Number(lv1User.inviter_id);
const comm2 = Number(((feeYuan * secondRefPct) / 100).toFixed(2));
if (comm2 > 0 && lv2UserId > 0 && lv2UserId !== participantUserId && lv2UserId !== lv1UserId) {
await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm2, lv2UserId]);
await pool.query(
`INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 2, ?, '已入账', NOW())`,
[`dst_act2_${ts}_${rand()}`, lv2UserId, participantUserId, fromUserName, activityId, feeYuan, comm2, secondRefPct]
);
}
}
}
} catch (_) {}
}
async joinActivity(activityId: string, params: { userId: number; payAmountCents: number }): Promise<any> {
const act = await this.getActivityById(activityId);
if (!act) throw new Error('活动不存在');
@@ -148,6 +226,14 @@ export class MysqlRepository {
VALUES (?, ?, ?, ?, ?, 0)
`, [ptId, activityId, params.userId, params.payAmountCents, verifyCode]);
// 建立核销码缓存索引 act:verify:{activity_id}:{verify_code} -> participant_id (TTL 24h)
const cacheKey = `act:verify:${activityId}:${verifyCode}`;
this.verifyCodeCache.set(cacheKey, {
participantId: ptId,
userId: params.userId,
expireAt: Date.now() + 24 * 3600 * 1000
});
// 写入一条财务流水
const finId = `fin_${Date.now()}`;
await pool.query(`
@@ -161,6 +247,7 @@ export class MysqlRepository {
userId: params.userId,
payAmountCents: params.payAmountCents,
verifyCode,
cacheKey,
checkinStatus: false,
};
}
@@ -185,18 +272,36 @@ export class MysqlRepository {
throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)');
}
await pool.query(`
// CAS 原子状态跃迁校验:防止高并发重复核销
const [updateRes]: any = await pool.query(`
UPDATE participants
SET checkin_status = 1, verified_at = NOW(), verified_by = ?
WHERE id = ?
WHERE id = ? AND checkin_status = 0
`, [operatorId, pt.id]);
if (!updateRes || updateRes.affectedRows === 0) {
throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)');
}
const cacheKey = `act:verify:${activityId}:${verifyCode}`;
this.verifyCodeCache.delete(cacheKey);
// 触发活动费用清算逻辑(划入发起人账户)及二级分销返佣
await this.settleActivityCheckinAndReferral(
activityId,
Number(act.creatorId),
pt.id,
Number(pt.user_id),
Number(pt.pay_amount_cents || 0)
);
return {
success: true,
participantId: pt.id,
activityId,
userId: pt.user_id,
verifyCode,
settledFeeYuan: Number((Number(pt.pay_amount_cents || 0) / 100).toFixed(2)),
verifiedAt: new Date(),
operatorId,
};
@@ -209,7 +314,7 @@ export class MysqlRepository {
p.id as participantId, p.activity_id as activityId, p.user_id as userId,
p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode,
p.checkin_status as checkinStatus,
a.title as activityTitle, a.game_category as gameCategory
a.title as activityTitle, a.game_category as gameCategory, a.creator_id as creatorId
FROM participants p
LEFT JOIN activities a ON p.activity_id = a.id
WHERE p.verify_code = ?
@@ -222,6 +327,9 @@ export class MysqlRepository {
type: 'ACTIVITY',
id: pt.participantId,
relatedId: pt.activityId,
userId: pt.userId,
creatorId: pt.creatorId,
payAmountCents: pt.payAmountCents,
nickname: `搭子_${pt.userId}`,
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150',
title: pt.activityTitle || '同城组局活动',
@@ -267,11 +375,22 @@ export class MysqlRepository {
}
if (info.type === 'ACTIVITY') {
await pool.query(`
const [updateRes]: any = await pool.query(`
UPDATE participants
SET checkin_status = 1, verified_at = NOW(), verified_by = ?
WHERE id = ?
WHERE id = ? AND checkin_status = 0
`, [operatorId, info.id]);
if (!updateRes || updateRes.affectedRows === 0) {
throw new Error(`核销码 [${verifyCode}] 已完成现场签到,不可重复核销(防重校验已生效)`);
}
this.verifyCodeCache.delete(`act:verify:${info.relatedId}:${verifyCode}`);
await this.settleActivityCheckinAndReferral(
String(info.relatedId),
Number(info.creatorId || operatorId),
String(info.id),
Number(info.userId || 0),
Number(info.payAmountCents || Math.round(Number(info.fee || 0) * 100))
);
} else if (info.type === 'ORDER') {
await pool.query(`
UPDATE orders
@@ -669,15 +788,17 @@ export class MysqlRepository {
const [rows]: any = await pool.query('SELECT * FROM companions WHERE id = ?', [id]);
const comp = rows && rows[0];
// 联动更新关联用户的角色与大神状态
// 联动更新关联用户的角色与大神状态及双身份标识 role_flags (玩家:1, 大神:2, 双身份:3)
if (comp && comp.user_id) {
const uId = Number(comp.user_id);
if (uId > 0) {
try {
if (action === 'approve') {
await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]);
await pool.query('UPDATE users SET role_flags = 3 WHERE id = ?', [uId]).catch(() => {});
} else {
await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]);
await pool.query('UPDATE users SET role_flags = 1 WHERE id = ?', [uId]).catch(() => {});
}
} catch (_) {
if (action === 'approve') {
@@ -1034,6 +1155,12 @@ export class MysqlRepository {
}
async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise<any> {
if (expertData.expertId) {
const comp = await this.resolveCompanionRow(expertData.expertId);
if (comp && comp.audit_status && comp.audit_status !== '已通过') {
throw new Error('大神档案未通过实名认证审核,无法接单');
}
}
await pool.query(`
UPDATE orders
SET status = '履约中', expert_id = ?, companion_name = ?, expert_avatar = ?
@@ -2480,6 +2607,9 @@ export class MysqlRepository {
const rejectReason = companionRow?.reject_reason || '';
const companionStatus = companionRow ? (companionRow.status || '接单中') : (certStatus === 2 ? '接单中' : '');
const isCertExpert = certStatus === 2 || roleHasExpert;
const roleFlags = isCertExpert ? 3 : 1;
return {
id: u.id,
userNo: u.userNo,
@@ -2489,8 +2619,10 @@ export class MysqlRepository {
gender: u.gender,
signature: u.signature || '',
role: u.role,
roleFlags,
balance: Number(u.balance || 0).toFixed(2),
commission: Number(u.commission || 0).toFixed(2),
commissionBalance: Number(u.commission || 0).toFixed(2),
invitedCount: Number(u.invitedCount || 0),
inviterId: u.inviterId ? Number(u.inviterId) : null,
orderCount: Number(u.orderCount || 0),
@@ -2500,7 +2632,7 @@ export class MysqlRepository {
certStatus,
rejectReason,
companionStatus,
isExpert: certStatus === 2 || roleHasExpert,
isExpert: isCertExpert,
status: u.status,
};
}
@@ -3348,23 +3480,48 @@ export class MysqlRepository {
if (inviterId === uid) {
throw new Error('不能绑定自己的邀请码');
}
const [invRows]: any = await pool.query('SELECT id, nickname FROM users WHERE id = ? LIMIT 1', [inviterId]);
const [invRows]: any = await pool.query('SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [inviterId]);
if (!invRows || invRows.length === 0) {
throw new Error('邀请码对应的推荐人不存在');
}
const inviter = invRows[0];
const level2InviterId = inviter.inviter_id ? Number(inviter.inviter_id) : null;
const [curRows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [uid]);
if (!curRows || curRows.length === 0) {
throw new Error('当前用户不存在');
}
if (curRows[0].inviter_id && Number(curRows[0].inviter_id) === inviterId) {
return { userId: uid, inviterId, inviterName: inviter.nickname, message: `已绑定推荐人:${inviter.nickname}` };
if (curRows[0].inviter_id) {
if (Number(curRows[0].inviter_id) === inviterId) {
return { userId: uid, inviterId, level2InviterId, inviterName: inviter.nickname, message: `已绑定推荐人:${inviter.nickname}` };
}
throw new Error('当前账号已终身绑定上级推荐人,不可重复修改绑定');
}
await pool.query('UPDATE users SET inviter_id = ? WHERE id = ?', [inviterId, uid]);
await pool.query('UPDATE users SET invited_count = COALESCE(invited_count, 0) + 1 WHERE id = ?', [inviterId]);
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS distribution_relations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT UNIQUE NOT NULL,
inviter_id INT NOT NULL,
level2_inviter_id INT DEFAULT NULL,
bind_source VARCHAR(64) DEFAULT 'INVITE_CODE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_inviter (inviter_id),
INDEX idx_level2_inviter (level2_inviter_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
await pool.query(
`INSERT INTO distribution_relations (user_id, inviter_id, level2_inviter_id, bind_source, created_at)
VALUES (?, ?, ?, 'SUNCODE_OR_INVITE', NOW())
ON DUPLICATE KEY UPDATE inviter_id = VALUES(inviter_id), level2_inviter_id = VALUES(level2_inviter_id)`,
[uid, inviterId, level2InviterId]
);
} catch (_) {}
return {
userId: uid,
inviterId,
level2InviterId,
inviterName: inviter.nickname,
message: `成功绑定推荐人:${inviter.nickname}`
};