const mysql = require('mysql2/promise') const { env } = require('../config/env') const { normalizePermissions } = require('../constants/permissions') function parseJson(value, fallback = []) { if (Array.isArray(value)) return value if (!value) return fallback try { return JSON.parse(value) } catch (error) { return fallback } } function toDate(value) { if (!value) return '' if (value instanceof Date) return value.toISOString().slice(0, 10) return String(value).slice(0, 10) } function toDateTime(value) { if (!value) return '' if (value instanceof Date) return value.toISOString().slice(0, 16).replace('T', ' ') return String(value).slice(0, 16).replace('T', ' ') } class MySqlRepository { constructor() { this.pool = mysql.createPool({ host: env.mysql.host, port: env.mysql.port, user: env.mysql.user, password: env.mysql.password, database: env.mysql.database, waitForConnections: true, connectionLimit: env.mysql.connectionLimit, namedPlaceholders: true, }) } async query(sql, params = {}) { const [rows] = await this.pool.execute(sql, params) return rows } rowToUser(row) { if (!row) return null return { id: row.id, username: row.username, passwordHash: row.password_hash, role: row.role, roleName: row.role_name, name: row.name, phone: row.phone, wechatOpenid: row.wechat_openid, wechatUnionid: row.wechat_unionid, wechatNickname: row.wechat_nickname, wechatAvatar: row.wechat_avatar, loginProvider: row.login_provider || 'password', phoneBound: Boolean(row.phone_bound), phoneBoundAt: toDateTime(row.phone_bound_at), wechatBoundAt: toDateTime(row.wechat_bound_at), status: row.status, lastLoginAt: toDateTime(row.last_login_at), createdAt: toDateTime(row.created_at), inviterCode: row.inviter_code, trialBookingLimit: Number(row.trial_booking_limit ?? 1), child: row.child_id ? { id: row.child_id, name: row.child_name, grade: row.child_grade, focus: row.child_focus, } : undefined, } } rowToRole(row) { if (!row) return null return { role: row.role, name: row.name, permissions: normalizePermissions(parseJson(row.permissions)), } } rowToCourse(row) { return { id: row.id, name: row.name, tags: parseJson(row.tags), nextTime: toDateTime(row.next_time), pointsBack: Number(row.points_back || 0), seats: Number(row.seats || 0), campus: row.campus, teacher: row.teacher, trialMinutes: Number(row.trial_minutes || 0), status: row.status, } } rowToAppointment(row) { return { id: row.id, userId: row.user_id, childId: row.child_id, courseId: row.course_id, status: row.status, createdAt: toDateTime(row.created_at), user: row.user_name ? { id: row.user_id, name: row.user_name, phone: row.user_phone, } : undefined, course: row.course_name ? { id: row.course_id, name: row.course_name, campus: row.course_campus, teacher: row.course_teacher, nextTime: toDateTime(row.course_next_time), pointsBack: Number(row.course_points_back || 0), } : undefined, } } rowToReward(row) { return { id: row.id, name: row.name, cost: Number(row.cost || 0), type: row.type, stock: Number(row.stock || 0), description: row.description || '', } } rowToPointAccount(row) { return { userId: row.user_id, available: Number(row.available || 0), pending: Number(row.pending || 0), frozen: Number(row.frozen || 0), expiring: Number(row.expiring || 0), } } rowToPointTransaction(row) { return { id: row.id, userId: row.user_id, title: row.title, amount: Number(row.amount || 0), status: row.status, referenceType: row.reference_type || '', referenceId: row.reference_id || '', createdAt: toDate(row.created_at), } } rowToReferral(row) { return { id: row.id, inviterId: row.inviter_id, inviteeId: row.invitee_id, name: row.name, phone: row.phone, childGrade: row.child_grade, status: row.status, reward: Number(row.reward || 0), channel: row.channel, } } rowToRedemption(row) { return { id: row.id, userId: row.user_id, itemId: row.item_id, status: row.status, costPoints: Number(row.cost_points || row.reward_cost || 0), voucherCode: row.voucher_code || '', voucherStatus: row.voucher_status || 'not_issued', createdAt: toDateTime(row.created_at), reviewedBy: row.reviewed_by || '', reviewedAt: toDateTime(row.reviewed_at), rejectedReason: row.rejected_reason || '', expireAt: toDateTime(row.expire_at), usedAt: toDateTime(row.used_at), usedBy: row.used_by || '', verifyNote: row.verify_note || '', user: row.user_name ? { id: row.user_id, name: row.user_name, phone: row.user_phone, } : undefined, reward: row.reward_name ? { id: row.item_id, name: row.reward_name, type: row.reward_type, cost: Number(row.reward_cost || row.cost_points || 0), } : undefined, } } rowToLead(row) { return { id: row.id, referralId: row.referral_id || '', inviteeId: row.invitee_id || '', parent: row.parent, source: row.source, stage: row.stage, advisor: row.advisor, risk: row.risk, channel: row.channel, createdAt: toDateTime(row.created_at), } } async findUserById(id) { const rows = await this.query('SELECT * FROM users WHERE id = :id LIMIT 1', { id }) return this.rowToUser(rows[0]) } async findUserByLoginAccount(account) { const value = String(account || '').trim() const rows = await this.query('SELECT * FROM users WHERE username = :value OR phone = :value OR id = :value OR wechat_openid = :value OR wechat_unionid = :value LIMIT 1', { value }) return this.rowToUser(rows[0]) } async findUserByInviteCode(inviterCode) { const rows = await this.query('SELECT * FROM users WHERE inviter_code = :inviterCode LIMIT 1', { inviterCode }) return this.rowToUser(rows[0]) } async findUserByPhone(phone) { const rows = await this.query('SELECT * FROM users WHERE phone = :phone LIMIT 1', { phone }) return this.rowToUser(rows[0]) } async findRole(roleName) { const rows = await this.query('SELECT * FROM roles WHERE role = :roleName LIMIT 1', { roleName }) return this.rowToRole(rows[0]) } async withPermissions(user) { if (!user) return null const role = await this.findRole(user.role) const { passwordHash, ...safeUser } = user return { ...safeUser, hasPassword: Boolean(user.passwordHash), roleName: role?.name || user.roleName || user.role, permissions: user.role === 'parent' ? [] : normalizePermissions(role?.permissions || []), } } async touchLogin(id) { await this.query('UPDATE users SET last_login_at = NOW() WHERE id = :id', { id }) return this.withPermissions(await this.findUserById(id)) } async listAccounts() { const rows = await this.query("SELECT * FROM users ORDER BY FIELD(role, 'super_admin', 'campus_admin', 'advisor', 'teacher', 'finance', 'parent'), id") return Promise.all(rows.map((row) => this.withPermissions(this.rowToUser(row)))) } async listParentUsers() { const rows = await this.query("SELECT * FROM users WHERE role = 'parent' ORDER BY id") return Promise.all(rows.map((row) => this.withPermissions(this.rowToUser(row)))) } async getRoleMatrix() { const rows = await this.query("SELECT * FROM roles WHERE role <> 'parent' ORDER BY FIELD(role, 'super_admin', 'campus_admin', 'advisor', 'teacher', 'finance')") return rows.map((row) => this.rowToRole(row)) } async updateRolePermissions(role, permissions) { await this.query('UPDATE roles SET permissions = :permissions WHERE role = :role', { role, permissions: JSON.stringify(permissions || []), }) return this.findRole(role) } async addUser(user) { await this.query( `INSERT INTO users ( id, username, password_hash, role, role_name, name, phone, wechat_openid, wechat_unionid, wechat_nickname, wechat_avatar, login_provider, phone_bound, phone_bound_at, wechat_bound_at, status, inviter_code, trial_booking_limit, child_id, child_name, child_grade, child_focus ) VALUES ( :id, :username, :passwordHash, :role, :roleName, :name, :phone, :wechatOpenid, :wechatUnionid, :wechatNickname, :wechatAvatar, :loginProvider, :phoneBound, :phoneBoundAt, :wechatBoundAt, :status, :inviterCode, :trialBookingLimit, :childId, :childName, :childGrade, :childFocus )`, { id: user.id, username: user.username || user.phone || user.id, passwordHash: user.passwordHash || null, role: user.role || 'parent', roleName: user.roleName || '家长', name: user.name, phone: user.phone || null, wechatOpenid: user.wechatOpenid || null, wechatUnionid: user.wechatUnionid || null, wechatNickname: user.wechatNickname || null, wechatAvatar: user.wechatAvatar || null, loginProvider: user.loginProvider || (user.wechatOpenid ? 'wechat' : 'password'), phoneBound: user.phoneBound === undefined ? Boolean(user.phone) : Boolean(user.phoneBound), phoneBoundAt: user.phoneBoundAt || (user.phone ? new Date().toISOString().slice(0, 16).replace('T', ' ') : null), wechatBoundAt: user.wechatBoundAt || (user.wechatOpenid ? new Date().toISOString().slice(0, 16).replace('T', ' ') : null), status: user.status || 'active', inviterCode: user.inviterCode, trialBookingLimit: Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1, childId: user.child?.id || null, childName: user.child?.name || null, childGrade: user.child?.grade || null, childFocus: user.child?.focus || null, }, ) return this.findUserById(user.id) } async updateUser(id, patch) { const current = await this.findUserById(id) if (!current) return null const next = { ...current, ...patch, child: patch.child !== undefined ? patch.child : current.child, } await this.query( `UPDATE users SET username = :username, password_hash = :passwordHash, role = :role, role_name = :roleName, name = :name, phone = :phone, wechat_openid = :wechatOpenid, wechat_unionid = :wechatUnionid, wechat_nickname = :wechatNickname, wechat_avatar = :wechatAvatar, login_provider = :loginProvider, phone_bound = :phoneBound, phone_bound_at = :phoneBoundAt, wechat_bound_at = :wechatBoundAt, status = :status, inviter_code = :inviterCode, trial_booking_limit = :trialBookingLimit, child_id = :childId, child_name = :childName, child_grade = :childGrade, child_focus = :childFocus WHERE id = :id`, { id, username: next.username || next.phone || id, passwordHash: next.passwordHash || null, role: next.role || 'parent', roleName: next.roleName || next.role || '家长', name: next.name, phone: next.phone || null, wechatOpenid: next.wechatOpenid || null, wechatUnionid: next.wechatUnionid || null, wechatNickname: next.wechatNickname || null, wechatAvatar: next.wechatAvatar || null, loginProvider: next.loginProvider || 'password', phoneBound: Boolean(next.phoneBound), phoneBoundAt: next.phoneBoundAt || null, wechatBoundAt: next.wechatBoundAt || null, status: next.status || 'active', inviterCode: next.inviterCode || null, trialBookingLimit: Number.isInteger(Number(next.trialBookingLimit)) ? Number(next.trialBookingLimit) : 1, childId: next.child?.id || null, childName: next.child?.name || null, childGrade: next.child?.grade || null, childFocus: next.child?.focus || null, }, ) return this.findUserById(id) } async deleteUser(id) { const user = await this.findUserById(id) if (!user) return null await this.query('DELETE FROM referrals WHERE inviter_id = :id OR invitee_id = :id', { id }) await this.query('DELETE FROM point_expiry_schedules WHERE user_id = :id', { id }) await this.query('DELETE FROM users WHERE id = :id', { id }) return user } async updateAccountRole(id, roleName) { const role = await this.findRole(roleName) const user = await this.findUserById(id) if (!role || !user) return null await this.query('UPDATE users SET role = :roleName, role_name = :roleDisplay WHERE id = :id', { id, roleName, roleDisplay: role.name }) return this.withPermissions(await this.findUserById(id)) } async updateAccountStatus(id, status) { const user = await this.findUserById(id) if (!user) return null await this.query('UPDATE users SET status = :status WHERE id = :id', { id, status }) return this.withPermissions(await this.findUserById(id)) } async getPointAccount(userId) { let rows = await this.query('SELECT * FROM point_accounts WHERE user_id = :userId LIMIT 1', { userId }) if (!rows[0]) { await this.query('INSERT INTO point_accounts (user_id, available, pending, frozen, expiring) VALUES (:userId, 0, 0, 0, 0)', { userId }) rows = await this.query('SELECT * FROM point_accounts WHERE user_id = :userId LIMIT 1', { userId }) } return this.rowToPointAccount(rows[0]) } async updatePointAccount(userId, patch) { await this.getPointAccount(userId) await this.query( 'UPDATE point_accounts SET available = :available, pending = :pending, frozen = :frozen, expiring = :expiring WHERE user_id = :userId', { userId, available: Number(patch.available || 0), pending: Number(patch.pending || 0), frozen: Number(patch.frozen || 0), expiring: Number(patch.expiring || 0), }, ) return this.getPointAccount(userId) } async addPointTransaction(transaction) { await this.query( `INSERT INTO point_transactions (id, user_id, title, amount, status, reference_type, reference_id, created_at) VALUES (:id, :userId, :title, :amount, :status, :referenceType, :referenceId, :createdAt)`, { ...transaction, referenceType: transaction.referenceType || null, referenceId: transaction.referenceId || null, }, ) return transaction } async listPointTransactions() { const rows = await this.query('SELECT * FROM point_transactions ORDER BY created_at DESC, id DESC') return rows.map((row) => this.rowToPointTransaction(row)) } async getPointTransactions(userId) { const rows = await this.query('SELECT * FROM point_transactions WHERE user_id = :userId ORDER BY created_at DESC, id DESC', { userId }) return rows.map((row) => this.rowToPointTransaction(row)) } async getPointExpiryRule() { const rows = await this.query('SELECT * FROM point_expiry_rule ORDER BY id LIMIT 1') const row = rows[0] return { title: row?.title || '积分有效期说明', validDays: Number(row?.valid_days || 180), description: row?.description || '', reminder: row?.reminder || '', } } async listPointExpirySchedules(userId) { const rows = await this.query( `SELECT * FROM point_expiry_schedules WHERE (:userId IS NULL OR user_id = :userId) ORDER BY expire_at ASC`, { userId: userId || null }, ) return rows.map((row) => ({ id: row.id, userId: row.user_id, points: Number(row.points || 0), expireAt: toDateTime(row.expire_at), source: row.source, })) } async getReferralRules() { const rows = await this.query('SELECT * FROM referral_rules ORDER BY sort_order ASC') return rows.map((row) => ({ event: row.event, title: row.title, inviter: Number(row.inviter || 0), invitee: Number(row.invitee || 0), sortOrder: Number(row.sort_order || 0), })) } async updateReferralRule(event, patch) { const rows = await this.query('SELECT * FROM referral_rules WHERE event = :event LIMIT 1', { event }) if (!rows[0]) return null const current = { event: rows[0].event, title: rows[0].title, inviter: Number(rows[0].inviter || 0), invitee: Number(rows[0].invitee || 0), sortOrder: Number(rows[0].sort_order || 0), } const next = { ...current, ...patch } await this.query( 'UPDATE referral_rules SET title = :title, inviter = :inviter, invitee = :invitee WHERE event = :event', { event, title: next.title, inviter: next.inviter, invitee: next.invitee }, ) return (await this.getReferralRules()).find((rule) => rule.event === event) } async getReferralsByInviter(inviterId) { const rows = await this.query('SELECT * FROM referrals WHERE inviter_id = :inviterId ORDER BY created_at DESC, id DESC', { inviterId }) return rows.map((row) => this.rowToReferral(row)) } async findReferralByInvitee(inviteeId) { const rows = await this.query('SELECT * FROM referrals WHERE invitee_id = :inviteeId ORDER BY created_at DESC, id DESC LIMIT 1', { inviteeId }) return rows[0] ? this.rowToReferral(rows[0]) : null } async findReferralById(id) { const rows = await this.query('SELECT * FROM referrals WHERE id = :id LIMIT 1', { id }) return rows[0] ? this.rowToReferral(rows[0]) : null } async findReferralForLead(lead = {}) { if (lead.referralId) { const referral = await this.findReferralById(lead.referralId) if (referral) return referral } if (lead.inviteeId) { const referral = await this.findReferralByInvitee(lead.inviteeId) if (referral) return referral } const parent = String(lead.parent || '').trim() if (!parent) return null const rows = await this.query( `SELECT r.*, CASE WHEN invitee.name = :parent THEN 40 WHEN r.name = :parent THEN 30 WHEN CONCAT('家长', RIGHT(COALESCE(invitee.phone, ''), 4)) = :parent THEN 20 WHEN CONCAT('家长', RIGHT(COALESCE(r.phone, ''), 4)) = :parent THEN 20 ELSE 0 END AS parent_score, CASE WHEN :source = '' THEN 8 WHEN inviter.name = :source OR inviter.id = :source OR inviter.phone = :source THEN 8 ELSE 0 END AS source_score, CASE WHEN :channel = '' OR r.channel = :channel THEN 4 ELSE 0 END AS channel_score FROM referrals r LEFT JOIN users invitee ON invitee.id = r.invitee_id LEFT JOIN users inviter ON inviter.id = r.inviter_id WHERE invitee.name = :parent OR r.name = :parent OR CONCAT('家长', RIGHT(COALESCE(invitee.phone, ''), 4)) = :parent OR CONCAT('家长', RIGHT(COALESCE(r.phone, ''), 4)) = :parent ORDER BY parent_score DESC, source_score DESC, channel_score DESC, r.created_at DESC, r.id DESC LIMIT 1`, { parent, source: String(lead.source || '').trim(), channel: String(lead.channel || '').trim(), }, ) return rows[0] ? this.rowToReferral(rows[0]) : null } async addReferral(referral) { await this.query( `INSERT INTO referrals (id, inviter_id, invitee_id, name, phone, child_grade, status, reward, channel) VALUES (:id, :inviterId, :inviteeId, :name, :phone, :childGrade, :status, :reward, :channel)`, referral, ) return referral } async updateReferral(id, patch) { const rows = await this.query('SELECT * FROM referrals WHERE id = :id LIMIT 1', { id }) if (!rows[0]) return null const current = this.rowToReferral(rows[0]) const next = { ...current, ...patch } await this.query( 'UPDATE referrals SET status = :status, reward = :reward WHERE id = :id', { id, status: next.status, reward: next.reward }, ) const nextRows = await this.query('SELECT * FROM referrals WHERE id = :id LIMIT 1', { id }) return nextRows[0] ? this.rowToReferral(nextRows[0]) : null } async updateReferralInviteeInfo(inviteeId, patch = {}) { const assignments = [] const params = { inviteeId } if (patch.name !== undefined) { assignments.push('name = :name') params.name = patch.name } if (patch.phone !== undefined) { assignments.push('phone = :phone') params.phone = patch.phone } if (patch.childGrade !== undefined) { assignments.push('child_grade = :childGrade') params.childGrade = patch.childGrade } if (!assignments.length) return [] await this.query(`UPDATE referrals SET ${assignments.join(', ')} WHERE invitee_id = :inviteeId`, params) const rows = await this.query('SELECT * FROM referrals WHERE invitee_id = :inviteeId ORDER BY created_at DESC, id DESC', { inviteeId }) return rows.map((row) => this.rowToReferral(row)) } async getCourses() { const rows = await this.query('SELECT * FROM courses ORDER BY created_at DESC, id') return rows.map((row) => this.rowToCourse(row)) } async findCourse(id) { const rows = await this.query('SELECT * FROM courses WHERE id = :id LIMIT 1', { id }) return rows[0] ? this.rowToCourse(rows[0]) : null } async addCourse(course) { await this.query( `INSERT INTO courses (id, name, tags, next_time, points_back, seats, campus, teacher, trial_minutes, status) VALUES (:id, :name, :tags, :nextTime, :pointsBack, :seats, :campus, :teacher, :trialMinutes, :status)`, { ...course, tags: JSON.stringify(course.tags || []) }, ) return this.findCourse(course.id) } async updateCourse(id, patch) { const course = await this.findCourse(id) if (!course) return null const next = { ...course, ...patch } await this.query( `UPDATE courses SET name = :name, tags = :tags, next_time = :nextTime, points_back = :pointsBack, seats = :seats, campus = :campus, teacher = :teacher, trial_minutes = :trialMinutes, status = :status WHERE id = :id`, { ...next, id, tags: JSON.stringify(next.tags || []) }, ) return this.findCourse(id) } async deleteCourse(id) { const course = await this.findCourse(id) if (!course) return null await this.query('DELETE FROM courses WHERE id = :id', { id }) return course } async addAppointment(appointment) { await this.query( 'INSERT INTO appointments (id, user_id, child_id, course_id, status, created_at) VALUES (:id, :userId, :childId, :courseId, :status, :createdAt)', appointment, ) return appointment } async findAppointmentById(id) { const rows = await this.query(`${this.appointmentSelectSql()} WHERE a.id = :id LIMIT 1`, { id }) return rows[0] ? this.rowToAppointment(rows[0]) : null } async updateAppointment(id, patch) { const appointment = await this.findAppointmentById(id) if (!appointment) return null const next = { ...appointment, ...patch } await this.query('UPDATE appointments SET status = :status WHERE id = :id', { id, status: next.status }) return this.findAppointmentById(id) } appointmentSelectSql() { return `SELECT a.*, u.name AS user_name, u.phone AS user_phone, c.name AS course_name, c.campus AS course_campus, c.teacher AS course_teacher, c.next_time AS course_next_time, c.points_back AS course_points_back FROM appointments a LEFT JOIN users u ON u.id = a.user_id LEFT JOIN courses c ON c.id = a.course_id` } async findAppointmentByUserId(userId) { const rows = await this.query(`${this.appointmentSelectSql()} WHERE a.user_id = :userId ORDER BY a.created_at DESC LIMIT 1`, { userId }) return rows[0] ? this.rowToAppointment(rows[0]) : null } async listAppointments(filters = {}) { const conditions = [] const params = {} if (filters.userId) { conditions.push('a.user_id = :userId') params.userId = filters.userId } if (filters.courseId) { conditions.push('a.course_id = :courseId') params.courseId = filters.courseId } if (filters.status && filters.status !== 'all') { conditions.push('a.status = :status') params.status = filters.status } if (filters.keyword) { conditions.push('(u.name LIKE :keyword OR u.phone LIKE :keyword OR c.name LIKE :keyword OR c.campus LIKE :keyword OR c.teacher LIKE :keyword)') params.keyword = `%${filters.keyword}%` } const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '' const rows = await this.query(`${this.appointmentSelectSql()}${where} ORDER BY a.created_at DESC, a.id DESC`, params) return rows.map((row) => this.rowToAppointment(row)) } async getRewards() { const rows = await this.query('SELECT * FROM rewards ORDER BY id') return rows.map((row) => this.rowToReward(row)) } async listRewards(filters = {}) { const conditions = [] const params = {} if (filters.keyword) { conditions.push('(name LIKE :keyword OR type LIKE :keyword OR description LIKE :keyword)') params.keyword = `%${filters.keyword}%` } if (filters.type && filters.type !== 'all') { conditions.push('type = :type') params.type = filters.type } if (filters.stockStatus === 'in_stock') conditions.push('stock > 0') if (filters.stockStatus === 'sold_out') conditions.push('stock <= 0') const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '' const rows = await this.query(`SELECT * FROM rewards${where} ORDER BY id`, params) return rows.map((row) => this.rowToReward(row)) } async findReward(id) { const rows = await this.query('SELECT * FROM rewards WHERE id = :id LIMIT 1', { id }) return rows[0] ? this.rowToReward(rows[0]) : null } async addReward(reward) { await this.query( 'INSERT INTO rewards (id, name, cost, type, stock, description) VALUES (:id, :name, :cost, :type, :stock, :description)', reward, ) return this.findReward(reward.id) } async updateReward(id, patch) { const reward = await this.findReward(id) if (!reward) return null const next = { ...reward, ...patch } await this.query('UPDATE rewards SET name = :name, type = :type, cost = :cost, stock = :stock, description = :description WHERE id = :id', { ...next, id }) return this.findReward(id) } async addRedemption(order) { await this.query( `INSERT INTO redemption_orders ( id, user_id, item_id, status, cost_points, voucher_code, voucher_status, created_at, reviewed_by, reviewed_at, rejected_reason, expire_at, used_at, used_by, verify_note ) VALUES ( :id, :userId, :itemId, :status, :costPoints, :voucherCode, :voucherStatus, :createdAt, :reviewedBy, :reviewedAt, :rejectedReason, :expireAt, :usedAt, :usedBy, :verifyNote )`, { ...order, costPoints: order.costPoints || 0, voucherCode: order.voucherCode || null, voucherStatus: order.voucherStatus || 'not_issued', reviewedBy: order.reviewedBy || null, reviewedAt: order.reviewedAt || null, rejectedReason: order.rejectedReason || null, expireAt: order.expireAt || null, usedAt: order.usedAt || null, usedBy: order.usedBy || null, verifyNote: order.verifyNote || null, }, ) return this.findRedemptionById(order.id) } redemptionSelectSql() { return `SELECT ro.*, u.name AS user_name, u.phone AS user_phone, r.name AS reward_name, r.type AS reward_type, r.cost AS reward_cost FROM redemption_orders ro LEFT JOIN users u ON u.id = ro.user_id LEFT JOIN rewards r ON r.id = ro.item_id` } async findRedemptionById(id) { const rows = await this.query(`${this.redemptionSelectSql()} WHERE ro.id = :id LIMIT 1`, { id }) return rows[0] ? this.rowToRedemption(rows[0]) : null } async findRedemptionByVoucherCode(voucherCode) { const rows = await this.query(`${this.redemptionSelectSql()} WHERE ro.voucher_code = :voucherCode LIMIT 1`, { voucherCode }) return rows[0] ? this.rowToRedemption(rows[0]) : null } async listRedemptions(filters = {}) { const conditions = [] const params = {} if (filters.userId) { conditions.push('ro.user_id = :userId') params.userId = filters.userId } if (filters.itemId) { conditions.push('ro.item_id = :itemId') params.itemId = filters.itemId } if (filters.status && filters.status !== 'all') { conditions.push('ro.status = :status') params.status = filters.status } if (filters.voucherStatus && filters.voucherStatus !== 'all') { conditions.push('ro.voucher_status = :voucherStatus') params.voucherStatus = filters.voucherStatus } if (filters.type && filters.type !== 'all') { conditions.push('r.type = :type') params.type = filters.type } if (filters.keyword) { conditions.push('(u.name LIKE :keyword OR u.phone LIKE :keyword OR r.name LIKE :keyword OR ro.voucher_code LIKE :keyword OR ro.id LIKE :keyword)') params.keyword = `%${filters.keyword}%` } const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '' const rows = await this.query(`${this.redemptionSelectSql()}${where} ORDER BY ro.created_at DESC, ro.id DESC`, params) return rows.map((row) => this.rowToRedemption(row)) } async updateRedemption(id, patch) { const columnMap = { status: 'status', costPoints: 'cost_points', voucherCode: 'voucher_code', voucherStatus: 'voucher_status', reviewedBy: 'reviewed_by', reviewedAt: 'reviewed_at', rejectedReason: 'rejected_reason', expireAt: 'expire_at', usedAt: 'used_at', usedBy: 'used_by', verifyNote: 'verify_note', } const assignments = [] const params = { id } Object.entries(columnMap).forEach(([key, column]) => { if (patch[key] !== undefined) { assignments.push(`${column} = :${key}`) params[key] = patch[key] || null } }) if (!assignments.length) return this.findRedemptionById(id) await this.query(`UPDATE redemption_orders SET ${assignments.join(', ')} WHERE id = :id`, params) return this.findRedemptionById(id) } async getLeads() { const rows = await this.query('SELECT * FROM leads ORDER BY created_at DESC, id DESC') return rows.map((row) => this.rowToLead(row)) } async getChannelStats() { const rows = await this.query('SELECT * FROM channel_stats ORDER BY leads DESC') return rows.map((row) => ({ channel: row.channel, leads: Number(row.leads || 0), attended: Number(row.attended || 0), purchased: Number(row.purchased || 0), })) } async addLead(lead) { await this.query( `INSERT INTO leads (id, referral_id, invitee_id, parent, source, stage, advisor, risk, channel) VALUES (:id, :referralId, :inviteeId, :parent, :source, :stage, :advisor, :risk, :channel)`, { id: lead.id, referralId: lead.referralId || null, inviteeId: lead.inviteeId || null, parent: lead.parent, source: lead.source, stage: lead.stage, advisor: lead.advisor, risk: lead.risk || '正常', channel: lead.channel || '小程序邀请码', }, ) return lead } async updateLeadStage(id, stage) { const rows = await this.query('SELECT * FROM leads WHERE id = :id LIMIT 1', { id }) if (!rows[0]) return null await this.query('UPDATE leads SET stage = :stage WHERE id = :id', { id, stage }) return { ...this.rowToLead(rows[0]), stage } } async updateLeadsByParent(parent, patch = {}) { const assignments = [] const params = { parent } if (patch.parent !== undefined) { assignments.push('parent = :nextParent') params.nextParent = patch.parent } if (patch.stage !== undefined) { assignments.push('stage = :stage') params.stage = patch.stage } if (patch.channel !== undefined) { assignments.push('channel = :channel') params.channel = patch.channel } if (!assignments.length) return [] await this.query(`UPDATE leads SET ${assignments.join(', ')} WHERE parent = :parent`, params) return this.getLeads() } async findLeadForReferral(referral, previous = {}) { if (!referral) return null const exactRows = await this.query( `SELECT * FROM leads WHERE (:referralId <> '' AND referral_id = :referralId) OR (:inviteeId <> '' AND invitee_id = :inviteeId) ORDER BY created_at DESC, id DESC LIMIT 1`, { referralId: referral.id || '', inviteeId: referral.inviteeId || '', }, ) if (exactRows[0]) return this.rowToLead(exactRows[0]) const inviter = referral.inviterId ? await this.findUserById(referral.inviterId) : null const sources = [inviter?.name, inviter?.id, inviter?.phone].filter(Boolean) const fallbackNames = [ referral.name, previous.name, referral.phone ? `家长${String(referral.phone).slice(-4)}` : '', previous.phone ? `家长${String(previous.phone).slice(-4)}` : '', ].filter(Boolean) if (!fallbackNames.length) return null const params = { channel: referral.channel || '', } const parentConditions = fallbackNames.map((name, index) => { const key = `parent${index}` params[key] = name return `parent = :${key}` }) const sourceConditions = sources.map((source, index) => { const key = `source${index}` params[key] = source return `source = :${key}` }) const rows = await this.query( `SELECT *, CASE WHEN ${sourceConditions.length ? sourceConditions.join(' OR ') : '1 = 1'} THEN 8 ELSE 0 END AS source_score, CASE WHEN :channel = '' OR channel = :channel OR channel = '' THEN 4 ELSE 0 END AS channel_score FROM leads WHERE (${parentConditions.join(' OR ')}) ORDER BY source_score DESC, channel_score DESC, created_at DESC, id DESC LIMIT 1`, params, ) return rows[0] ? this.rowToLead(rows[0]) : null } async updateLeadInfoByReferral(referral, patch = {}, previous = {}) { const lead = await this.findLeadForReferral(referral, previous) if (!lead) return [] const nextPatch = { ...patch } if (referral.id && !lead.referralId) nextPatch.referralId = referral.id if (referral.inviteeId && !lead.inviteeId) nextPatch.inviteeId = referral.inviteeId const columnMap = { referralId: 'referral_id', inviteeId: 'invitee_id', parent: 'parent', source: 'source', stage: 'stage', advisor: 'advisor', risk: 'risk', channel: 'channel', } const assignments = [] const params = { id: lead.id } Object.entries(columnMap).forEach(([key, column]) => { if (nextPatch[key] !== undefined) { assignments.push(`${column} = :${key}`) params[key] = nextPatch[key] || null } }) if (!assignments.length) return [lead] await this.query(`UPDATE leads SET ${assignments.join(', ')} WHERE id = :id`, params) const rows = await this.query('SELECT * FROM leads WHERE id = :id LIMIT 1', { id: lead.id }) return rows[0] ? [this.rowToLead(rows[0])] : [] } async updateLeadStageByReferral(referral, stage) { return this.updateLeadInfoByReferral(referral, { stage }) } } module.exports = { MySqlRepository }