You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1034 lines
36 KiB

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 }