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
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 }
|