@ -0,0 +1,28 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
release/
|
||||||
|
.codex-tmp
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.prod
|
||||||
|
backend/uploads/*
|
||||||
|
!backend/uploads/.gitkeep
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
NODE_ENV=production
|
||||||
|
PORT=3100
|
||||||
|
PUBLIC_BASE_URL=http://127.0.0.1:3100
|
||||||
|
# Optional. Leave empty to use backend/public/pc-admin.
|
||||||
|
PC_ADMIN_DIST_DIR=
|
||||||
|
# Reserved H5 preview config. Keep disabled for the current PC-only release.
|
||||||
|
ENABLE_H5_PREVIEW=false
|
||||||
|
# Optional. When H5 preview is enabled later, leave empty to use backend/public/h5.
|
||||||
|
H5_DIST_DIR=
|
||||||
|
|
||||||
|
# Signed login session. Replace with a long random value before production deployment.
|
||||||
|
AUTH_SESSION_SECRET=replace_with_at_least_32_random_characters
|
||||||
|
# Default: 168 hours (7 days).
|
||||||
|
AUTH_SESSION_TTL_HOURS=168
|
||||||
|
|
||||||
|
DB_CLIENT=mysql
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=lifanghe
|
||||||
|
MYSQL_PASSWORD=lifanghe123
|
||||||
|
MYSQL_DATABASE=lifanghe_education
|
||||||
|
MYSQL_CONNECTION_LIMIT=10
|
||||||
|
|
||||||
|
# WeChat Mini Program config for login, phone binding, and mini program code generation.
|
||||||
|
WECHAT_APP_ID=wx_your_app_id
|
||||||
|
WECHAT_APP_SECRET=your_wechat_app_secret
|
||||||
|
# Use release for production, trial for experience version, develop for development version.
|
||||||
|
WECHAT_ENV_VERSION=release
|
||||||
|
|
||||||
|
# SMS verification config.
|
||||||
|
# In production set SMS_ALLOW_TEST_CODE=false so the fixed test code cannot pass.
|
||||||
|
SMS_PROVIDER=aliyun
|
||||||
|
SMS_ALLOW_TEST_CODE=true
|
||||||
|
SMS_TEST_CODE=111111
|
||||||
|
SMS_CODE_LENGTH=6
|
||||||
|
SMS_CODE_TTL_SECONDS=300
|
||||||
|
SMS_CODE_COOLDOWN_SECONDS=60
|
||||||
|
|
||||||
|
# Aliyun SMS OpenAPI config. The SMS template must contain a variable named ${code}
|
||||||
|
# unless ALIYUN_SMS_TEMPLATE_PARAM_KEY is changed.
|
||||||
|
ALIYUN_ACCESS_KEY_ID=your_aliyun_access_key_id
|
||||||
|
ALIYUN_ACCESS_KEY_SECRET=your_aliyun_access_key_secret
|
||||||
|
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||||
|
ALIYUN_SMS_SIGN_NAME=your_sms_sign_name
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=SMS_000000000
|
||||||
|
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# 立方和教育推广机制后端
|
||||||
|
|
||||||
|
Node + Express 分层接口,用内存仓库模拟数据库,方便后续替换为 MySQL、PostgreSQL 或 MongoDB。
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认服务地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3100/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main Modules
|
||||||
|
|
||||||
|
- `controllers/`: HTTP 入参和出参
|
||||||
|
- `services/`: 推荐、积分、课程、管理台业务规则
|
||||||
|
- `repositories/`: 数据访问层
|
||||||
|
- `data/seed.js`: 初始模拟数据
|
||||||
|
- `routes/`: API 路由聚合
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "lifanghe-education-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"start": "node src/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"mysql2": "^3.22.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
ALTER TABLE users MODIFY password_hash CHAR(64) NULL;
|
||||||
|
ALTER TABLE users MODIFY phone VARCHAR(30) NULL;
|
||||||
|
|
||||||
|
SET @schema_name = DATABASE();
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN wechat_openid VARCHAR(80) NULL AFTER phone',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_openid'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN wechat_unionid VARCHAR(80) NULL AFTER wechat_openid',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_unionid'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN wechat_nickname VARCHAR(80) NULL AFTER wechat_unionid',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_nickname'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN wechat_avatar VARCHAR(500) NULL AFTER wechat_nickname',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_avatar'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN login_provider VARCHAR(30) NOT NULL DEFAULT ''password'' AFTER wechat_avatar',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'login_provider'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN phone_bound TINYINT(1) NOT NULL DEFAULT 1 AFTER login_provider',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone_bound'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN phone_bound_at VARCHAR(32) NULL AFTER phone_bound',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone_bound_at'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN wechat_bound_at VARCHAR(32) NULL AFTER phone_bound_at',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_bound_at'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD UNIQUE KEY uk_users_wechat_openid (wechat_openid)',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_openid' AND NON_UNIQUE = 0
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD UNIQUE KEY uk_users_wechat_unionid (wechat_unionid)',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_unionid' AND NON_UNIQUE = 0
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE users ADD INDEX idx_users_login_provider (login_provider)',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_login_provider'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
login_provider = COALESCE(login_provider, 'password'),
|
||||||
|
phone_bound = IF(phone IS NULL OR phone = '', 0, 1),
|
||||||
|
phone_bound_at = IF((phone IS NULL OR phone = ''), NULL, COALESCE(phone_bound_at, last_login_at))
|
||||||
|
WHERE id IS NOT NULL;
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
DELETE FROM redemption_orders WHERE user_id IN ('parent_001', 'parent_002', 'parent_003');
|
||||||
|
DELETE FROM appointments WHERE user_id IN ('parent_001', 'parent_002', 'parent_003');
|
||||||
|
DELETE FROM referrals WHERE id IN ('rel_001', 'rel_002', 'rel_003');
|
||||||
|
DELETE FROM point_expiry_schedules WHERE id IN ('exp_001', 'exp_002', 'exp_003');
|
||||||
|
DELETE FROM point_transactions WHERE id IN ('pt_001', 'pt_002', 'pt_003', 'pt_004', 'pt_005');
|
||||||
|
DELETE FROM point_accounts WHERE user_id IN ('parent_001', 'parent_002', 'parent_003');
|
||||||
|
DELETE FROM leads WHERE id IN ('lead_001', 'lead_002', 'lead_003', 'lead_004', 'lead_005');
|
||||||
|
DELETE FROM courses WHERE id IN ('course_001', 'course_002', 'course_003', 'course_004');
|
||||||
|
DELETE FROM channel_stats WHERE channel IN ('朋友圈海报', '微信好友', 'H5链接', '顾问二维码');
|
||||||
|
DELETE FROM users WHERE id IN ('parent_001', 'parent_002', 'parent_003', 'campus_001', 'advisor_001', 'teacher_001', 'finance_001');
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY()
|
||||||
|
WHERE role = 'parent';
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY_APPEND(permissions, '$', 'points:rule')
|
||||||
|
WHERE role IN ('campus_admin', 'finance')
|
||||||
|
AND JSON_CONTAINS(permissions, JSON_QUOTE('points:rule')) = 0
|
||||||
|
AND JSON_CONTAINS(permissions, JSON_QUOTE('*:*:*')) = 0;
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS appointment_duplicates_backup LIKE appointments;
|
||||||
|
|
||||||
|
INSERT INTO appointment_duplicates_backup
|
||||||
|
SELECT a.*
|
||||||
|
FROM appointments a
|
||||||
|
JOIN appointments b
|
||||||
|
ON a.user_id = b.user_id
|
||||||
|
AND (a.created_at < b.created_at OR (a.created_at = b.created_at AND a.id < b.id))
|
||||||
|
LEFT JOIN appointment_duplicates_backup bak ON bak.id = a.id
|
||||||
|
WHERE bak.id IS NULL;
|
||||||
|
|
||||||
|
DELETE a
|
||||||
|
FROM appointments a
|
||||||
|
JOIN appointments b
|
||||||
|
ON a.user_id = b.user_id
|
||||||
|
AND (a.created_at < b.created_at OR (a.created_at = b.created_at AND a.id < b.id));
|
||||||
|
|
||||||
|
SET @schema_name = DATABASE();
|
||||||
|
SET @has_unique := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name
|
||||||
|
AND TABLE_NAME = 'appointments'
|
||||||
|
AND INDEX_NAME = 'uk_appointments_user'
|
||||||
|
);
|
||||||
|
SET @add_unique_sql := IF(
|
||||||
|
@has_unique = 0,
|
||||||
|
'ALTER TABLE appointments ADD UNIQUE KEY uk_appointments_user (user_id)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_unique_stmt FROM @add_unique_sql;
|
||||||
|
EXECUTE add_unique_stmt;
|
||||||
|
DEALLOCATE PREPARE add_unique_stmt;
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
ALTER TABLE redemption_orders
|
||||||
|
ADD COLUMN cost_points INT NOT NULL DEFAULT 0 AFTER status,
|
||||||
|
ADD COLUMN voucher_code VARCHAR(60) NULL UNIQUE AFTER cost_points,
|
||||||
|
ADD COLUMN voucher_status VARCHAR(40) NOT NULL DEFAULT 'not_issued' AFTER voucher_code,
|
||||||
|
ADD COLUMN reviewed_by VARCHAR(40) NULL AFTER created_at,
|
||||||
|
ADD COLUMN reviewed_at VARCHAR(40) NULL AFTER reviewed_by,
|
||||||
|
ADD COLUMN rejected_reason VARCHAR(255) NULL AFTER reviewed_at,
|
||||||
|
ADD COLUMN expire_at VARCHAR(40) NULL AFTER rejected_reason,
|
||||||
|
ADD COLUMN used_at VARCHAR(40) NULL AFTER expire_at,
|
||||||
|
ADD COLUMN used_by VARCHAR(40) NULL AFTER used_at,
|
||||||
|
ADD COLUMN verify_note VARCHAR(255) NULL AFTER used_by,
|
||||||
|
ADD INDEX idx_redemption_status (status),
|
||||||
|
ADD INDEX idx_redemption_voucher (voucher_code);
|
||||||
|
|
||||||
|
UPDATE redemption_orders ro
|
||||||
|
LEFT JOIN rewards r ON r.id = ro.item_id
|
||||||
|
SET ro.cost_points = COALESCE(NULLIF(ro.cost_points, 0), r.cost, 0),
|
||||||
|
ro.voucher_status = CASE
|
||||||
|
WHEN ro.status = 'approved' THEN 'issued'
|
||||||
|
WHEN ro.status = 'rejected' THEN 'void'
|
||||||
|
ELSE ro.voucher_status
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY(
|
||||||
|
'dashboard:view',
|
||||||
|
'system:user:view',
|
||||||
|
'system:role:view',
|
||||||
|
'leads:view',
|
||||||
|
'leads:advance',
|
||||||
|
'points:view',
|
||||||
|
'points:redemption:view',
|
||||||
|
'points:redemption:review',
|
||||||
|
'points:voucher:verify',
|
||||||
|
'courses:view',
|
||||||
|
'courses:add',
|
||||||
|
'courses:edit',
|
||||||
|
'statistics:view'
|
||||||
|
)
|
||||||
|
WHERE role = 'campus_admin';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY('dashboard:view', 'leads:view', 'leads:advance', 'courses:view', 'points:view', 'points:redemption:view')
|
||||||
|
WHERE role = 'advisor';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY(
|
||||||
|
'dashboard:view',
|
||||||
|
'points:view',
|
||||||
|
'points:adjust',
|
||||||
|
'points:reward',
|
||||||
|
'points:redemption:view',
|
||||||
|
'points:redemption:review',
|
||||||
|
'points:voucher:verify',
|
||||||
|
'statistics:view'
|
||||||
|
)
|
||||||
|
WHERE role = 'finance';
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY()
|
||||||
|
WHERE role = 'parent';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY('*:*:*')
|
||||||
|
WHERE role = 'super_admin';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY(
|
||||||
|
'dashboard:view',
|
||||||
|
'system:user:view',
|
||||||
|
'system:role:view',
|
||||||
|
'leads:view',
|
||||||
|
'leads:advance',
|
||||||
|
'points:view',
|
||||||
|
'points:rule',
|
||||||
|
'courses:view',
|
||||||
|
'courses:add',
|
||||||
|
'courses:edit',
|
||||||
|
'statistics:view'
|
||||||
|
)
|
||||||
|
WHERE role = 'campus_admin';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY('dashboard:view', 'leads:view', 'leads:advance', 'courses:view', 'points:view')
|
||||||
|
WHERE role = 'advisor';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY('dashboard:view', 'leads:view', 'courses:view')
|
||||||
|
WHERE role = 'teacher';
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_ARRAY('dashboard:view', 'points:view', 'points:adjust', 'points:rule', 'points:reward', 'statistics:view')
|
||||||
|
WHERE role = 'finance';
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE leads
|
||||||
|
ADD COLUMN referral_id VARCHAR(40) NULL AFTER id,
|
||||||
|
ADD COLUMN invitee_id VARCHAR(40) NULL AFTER referral_id,
|
||||||
|
ADD INDEX idx_leads_referral (referral_id),
|
||||||
|
ADD INDEX idx_leads_invitee (invitee_id);
|
||||||
|
|
||||||
|
UPDATE leads l
|
||||||
|
JOIN referrals r
|
||||||
|
JOIN users inviter ON inviter.id = r.inviter_id
|
||||||
|
LEFT JOIN users invitee ON invitee.id = r.invitee_id
|
||||||
|
SET l.referral_id = r.id,
|
||||||
|
l.invitee_id = r.invitee_id,
|
||||||
|
l.parent = COALESCE(invitee.name, r.name)
|
||||||
|
WHERE l.referral_id IS NULL
|
||||||
|
AND (
|
||||||
|
l.parent = r.name
|
||||||
|
OR l.parent = COALESCE(invitee.name, r.name)
|
||||||
|
OR l.parent = CONCAT('家长', RIGHT(r.phone, 4))
|
||||||
|
)
|
||||||
|
AND (l.source = inviter.name OR l.source = inviter.id OR l.source = inviter.phone)
|
||||||
|
AND (l.channel = r.channel OR l.channel = '' OR r.channel = '');
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
SET @schema_name = DATABASE();
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE point_transactions ADD COLUMN reference_type VARCHAR(40) NULL AFTER status',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'point_transactions' AND COLUMN_NAME = 'reference_type'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE point_transactions ADD COLUMN reference_id VARCHAR(60) NULL AFTER reference_type',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'point_transactions' AND COLUMN_NAME = 'reference_id'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = (
|
||||||
|
SELECT IF(COUNT(*) = 0,
|
||||||
|
'ALTER TABLE point_transactions ADD INDEX idx_point_transactions_reference (reference_type, reference_id)',
|
||||||
|
'SELECT 1'
|
||||||
|
)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'point_transactions' AND INDEX_NAME = 'idx_point_transactions_reference'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'dashboard:view')), 'dashboard:overview:view')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'dashboard:view') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'leads:view')), 'leads:referral:view')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'leads:view') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'leads:advance')), 'leads:referral:advance')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'leads:advance') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:view')), 'points:account:view')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'points:view') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:adjust')), 'points:account:adjust')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'points:adjust') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:rule')), 'points:rule:edit')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'points:rule') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:reward')), 'points:reward:edit')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'points:reward') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:view')), 'courses:trial:view')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'courses:view') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:add')), 'courses:trial:add')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'courses:add') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:edit')), 'courses:trial:edit')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'courses:edit') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:delete')), 'courses:trial:delete')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'courses:delete') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'statistics:view')), 'statistics:channel:view')
|
||||||
|
WHERE JSON_SEARCH(permissions, 'one', 'statistics:view') IS NOT NULL;
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
USE lifanghe_education;
|
||||||
|
|
||||||
|
SET @schema_name = DATABASE();
|
||||||
|
|
||||||
|
SET @has_trial_booking_limit := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name
|
||||||
|
AND TABLE_NAME = 'users'
|
||||||
|
AND COLUMN_NAME = 'trial_booking_limit'
|
||||||
|
);
|
||||||
|
SET @add_trial_booking_limit_sql := IF(
|
||||||
|
@has_trial_booking_limit = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN trial_booking_limit INT NOT NULL DEFAULT 1 AFTER child_focus',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_trial_booking_limit_stmt FROM @add_trial_booking_limit_sql;
|
||||||
|
EXECUTE add_trial_booking_limit_stmt;
|
||||||
|
DEALLOCATE PREPARE add_trial_booking_limit_stmt;
|
||||||
|
|
||||||
|
SET @has_unique_user_index := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name
|
||||||
|
AND TABLE_NAME = 'appointments'
|
||||||
|
AND INDEX_NAME = 'uk_appointments_user'
|
||||||
|
);
|
||||||
|
SET @drop_unique_user_index_sql := IF(
|
||||||
|
@has_unique_user_index > 0,
|
||||||
|
'ALTER TABLE appointments DROP INDEX uk_appointments_user',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE drop_unique_user_index_stmt FROM @drop_unique_user_index_sql;
|
||||||
|
EXECUTE drop_unique_user_index_stmt;
|
||||||
|
DEALLOCATE PREPARE drop_unique_user_index_stmt;
|
||||||
|
|
||||||
|
SET @has_appointment_user_index := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name
|
||||||
|
AND TABLE_NAME = 'appointments'
|
||||||
|
AND INDEX_NAME = 'idx_appointments_user'
|
||||||
|
);
|
||||||
|
SET @add_appointment_user_index_sql := IF(
|
||||||
|
@has_appointment_user_index = 0,
|
||||||
|
'ALTER TABLE appointments ADD INDEX idx_appointments_user (user_id)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_appointment_user_index_stmt FROM @add_appointment_user_index_sql;
|
||||||
|
EXECUTE add_appointment_user_index_stmt;
|
||||||
|
DEALLOCATE PREPARE add_appointment_user_index_stmt;
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const cors = require('cors')
|
||||||
|
const morgan = require('morgan')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { env } = require('./config/env')
|
||||||
|
const { router } = require('./routes')
|
||||||
|
const { errorHandler } = require('./middleware/errorHandler')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.disable('etag')
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json())
|
||||||
|
app.use(morgan('dev'))
|
||||||
|
app.use('/uploads', express.static(path.resolve(__dirname, '../uploads')))
|
||||||
|
|
||||||
|
const pcAdminDistDir = env.pcAdminDistDir
|
||||||
|
? path.resolve(env.pcAdminDistDir)
|
||||||
|
: path.resolve(__dirname, '../public/pc-admin')
|
||||||
|
const pcAdminIndex = path.join(pcAdminDistDir, 'index.html')
|
||||||
|
const hasPcAdmin = fs.existsSync(pcAdminIndex)
|
||||||
|
if (hasPcAdmin) {
|
||||||
|
app.use(express.static(pcAdminDistDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ ok: true, service: 'lifanghe-education-api' })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/api', (req, res, next) => {
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
Expires: '0',
|
||||||
|
'Surrogate-Control': 'no-store',
|
||||||
|
})
|
||||||
|
next()
|
||||||
|
}, router)
|
||||||
|
|
||||||
|
if (hasPcAdmin) {
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) return next()
|
||||||
|
return res.sendFile(pcAdminIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
module.exports = { app }
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
function loadDotEnv(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return
|
||||||
|
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
|
||||||
|
if (!match) continue
|
||||||
|
const key = match[1]
|
||||||
|
let value = match[2].trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined || process.env[key] === '') {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDotEnv(path.resolve(__dirname, '../../.env'))
|
||||||
|
|
||||||
|
function readBool(value, fallback = false) {
|
||||||
|
if (value === undefined || value === '') return fallback
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development'
|
||||||
|
const sessionSecret = process.env.AUTH_SESSION_SECRET || 'lifanghe-development-session-secret'
|
||||||
|
if (nodeEnv === 'production' && (!process.env.AUTH_SESSION_SECRET || sessionSecret.length < 32)) {
|
||||||
|
throw new Error('生产环境必须配置至少 32 位的 AUTH_SESSION_SECRET')
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
nodeEnv,
|
||||||
|
port: Number(process.env.PORT || 3100),
|
||||||
|
publicBaseUrl: process.env.PUBLIC_BASE_URL || '',
|
||||||
|
pcAdminDistDir: process.env.PC_ADMIN_DIST_DIR || '',
|
||||||
|
dbClient: process.env.DB_CLIENT || (process.env.MYSQL_HOST ? 'mysql' : 'memory'),
|
||||||
|
auth: {
|
||||||
|
sessionSecret,
|
||||||
|
sessionTtlHours: Number(process.env.AUTH_SESSION_TTL_HOURS || 168),
|
||||||
|
},
|
||||||
|
mysql: {
|
||||||
|
host: process.env.MYSQL_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.MYSQL_PORT || 3306),
|
||||||
|
user: process.env.MYSQL_USER || 'lifanghe',
|
||||||
|
password: process.env.MYSQL_PASSWORD || 'lifanghe123',
|
||||||
|
database: process.env.MYSQL_DATABASE || 'lifanghe_education',
|
||||||
|
connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10),
|
||||||
|
},
|
||||||
|
wechat: {
|
||||||
|
appId: process.env.WECHAT_APP_ID || '',
|
||||||
|
appSecret: process.env.WECHAT_APP_SECRET || '',
|
||||||
|
envVersion: process.env.WECHAT_ENV_VERSION || 'release',
|
||||||
|
},
|
||||||
|
sms: {
|
||||||
|
provider: process.env.SMS_PROVIDER || 'aliyun',
|
||||||
|
allowTestCode: readBool(process.env.SMS_ALLOW_TEST_CODE, true),
|
||||||
|
testCode: process.env.SMS_TEST_CODE || '111111',
|
||||||
|
codeLength: Number(process.env.SMS_CODE_LENGTH || 6),
|
||||||
|
codeTtlSeconds: Number(process.env.SMS_CODE_TTL_SECONDS || 300),
|
||||||
|
codeCooldownSeconds: Number(process.env.SMS_CODE_COOLDOWN_SECONDS || 60),
|
||||||
|
aliyun: {
|
||||||
|
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
|
||||||
|
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
|
||||||
|
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
|
||||||
|
signName: process.env.ALIYUN_SMS_SIGN_NAME || '',
|
||||||
|
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
|
||||||
|
templateParamKey: process.env.ALIYUN_SMS_TEMPLATE_PARAM_KEY || 'code',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { env }
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
const PERMISSION_TREE = [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: '数据驾驶舱',
|
||||||
|
children: [
|
||||||
|
{ id: 'dashboard:overview:view', label: '查看驾驶舱', permission: 'dashboard:overview:view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
label: '系统管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'system:user',
|
||||||
|
label: '用户管理',
|
||||||
|
children: [
|
||||||
|
{ id: 'system:user:view', label: '查看用户', permission: 'system:user:view' },
|
||||||
|
{ id: 'system:user:add', label: '新增用户', permission: 'system:user:add' },
|
||||||
|
{ id: 'system:user:edit', label: '编辑用户', permission: 'system:user:edit' },
|
||||||
|
{ id: 'system:user:delete', label: '删除用户', permission: 'system:user:delete' },
|
||||||
|
{ id: 'system:user:role', label: '分配角色', permission: 'system:user:role' },
|
||||||
|
{ id: 'system:user:status', label: '启停账号', permission: 'system:user:status' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system:role',
|
||||||
|
label: '角色权限',
|
||||||
|
children: [
|
||||||
|
{ id: 'system:role:view', label: '查看角色', permission: 'system:role:view' },
|
||||||
|
{ id: 'system:role:edit', label: '配置权限', permission: 'system:role:edit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leads',
|
||||||
|
label: '线索管理',
|
||||||
|
children: [
|
||||||
|
{ id: 'leads:referral:view', label: '查看线索', permission: 'leads:referral:view' },
|
||||||
|
{ id: 'leads:referral:advance', label: '推进线索', permission: 'leads:referral:advance' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'points',
|
||||||
|
label: '积分管理',
|
||||||
|
children: [
|
||||||
|
{ id: 'points:account:view', label: '查看积分', permission: 'points:account:view' },
|
||||||
|
{ id: 'points:account:adjust', label: '调整积分', permission: 'points:account:adjust' },
|
||||||
|
{ id: 'points:rule:edit', label: '维护积分规则', permission: 'points:rule:edit' },
|
||||||
|
{ id: 'points:reward:edit', label: '维护兑换项', permission: 'points:reward:edit' },
|
||||||
|
{ id: 'points:redemption:view', label: '查看兑换申请', permission: 'points:redemption:view' },
|
||||||
|
{ id: 'points:redemption:review', label: '审核兑换申请', permission: 'points:redemption:review' },
|
||||||
|
{ id: 'points:voucher:verify', label: '核销权益券码', permission: 'points:voucher:verify' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'courses',
|
||||||
|
label: '课程管理',
|
||||||
|
children: [
|
||||||
|
{ id: 'courses:trial:view', label: '查看课程', permission: 'courses:trial:view' },
|
||||||
|
{ id: 'courses:trial:add', label: '新增课程', permission: 'courses:trial:add' },
|
||||||
|
{ id: 'courses:trial:edit', label: '编辑课程', permission: 'courses:trial:edit' },
|
||||||
|
{ id: 'courses:trial:delete', label: '删除课程', permission: 'courses:trial:delete' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'statistics',
|
||||||
|
label: '数据统计',
|
||||||
|
children: [
|
||||||
|
{ id: 'statistics:channel:view', label: '查看统计', permission: 'statistics:channel:view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROLE_PERMISSIONS = {
|
||||||
|
parent: [],
|
||||||
|
super_admin: ['*:*:*'],
|
||||||
|
campus_admin: [
|
||||||
|
'dashboard:overview:view',
|
||||||
|
'system:user:view',
|
||||||
|
'system:role:view',
|
||||||
|
'leads:referral:view',
|
||||||
|
'leads:referral:advance',
|
||||||
|
'points:account:view',
|
||||||
|
'points:rule:edit',
|
||||||
|
'points:redemption:view',
|
||||||
|
'points:redemption:review',
|
||||||
|
'points:voucher:verify',
|
||||||
|
'courses:trial:view',
|
||||||
|
'courses:trial:add',
|
||||||
|
'courses:trial:edit',
|
||||||
|
'statistics:channel:view',
|
||||||
|
],
|
||||||
|
advisor: ['dashboard:overview:view', 'leads:referral:view', 'leads:referral:advance', 'courses:trial:view', 'points:account:view', 'points:redemption:view'],
|
||||||
|
teacher: ['dashboard:overview:view', 'leads:referral:view', 'courses:trial:view'],
|
||||||
|
finance: ['dashboard:overview:view', 'points:account:view', 'points:account:adjust', 'points:rule:edit', 'points:reward:edit', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify', 'statistics:channel:view'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenPermissions(nodes = PERMISSION_TREE) {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
const current = node.permission ? [node.permission] : []
|
||||||
|
return current.concat(flattenPermissions(node.children || []))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PERMISSIONS = flattenPermissions()
|
||||||
|
|
||||||
|
const LEGACY_PERMISSION_MAP = {
|
||||||
|
'dashboard:view': ['dashboard:overview:view'],
|
||||||
|
'leads:view': ['leads:referral:view'],
|
||||||
|
'leads:advance': ['leads:referral:advance'],
|
||||||
|
'points:view': ['points:account:view'],
|
||||||
|
'points:adjust': ['points:account:adjust'],
|
||||||
|
'points:rule': ['points:rule:edit'],
|
||||||
|
'points:reward': ['points:reward:edit'],
|
||||||
|
'courses:view': ['courses:trial:view'],
|
||||||
|
'courses:add': ['courses:trial:add'],
|
||||||
|
'courses:edit': ['courses:trial:edit'],
|
||||||
|
'courses:delete': ['courses:trial:delete'],
|
||||||
|
'statistics:view': ['statistics:channel:view'],
|
||||||
|
账号权限: ['system:user:view', 'system:user:add', 'system:user:edit', 'system:user:delete', 'system:user:role', 'system:user:status', 'system:role:view', 'system:role:edit'],
|
||||||
|
活动配置: ['courses:trial:view', 'courses:trial:add', 'courses:trial:edit', 'courses:trial:delete'],
|
||||||
|
积分规则: ['points:account:view', 'points:account:adjust', 'points:rule:edit', 'points:reward:edit', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify'],
|
||||||
|
财务数据: ['points:account:view', 'statistics:channel:view'],
|
||||||
|
线索分配: ['leads:referral:view', 'leads:referral:advance'],
|
||||||
|
校区数据: ['dashboard:overview:view', 'statistics:channel:view', 'courses:trial:view'],
|
||||||
|
兑换审核: ['points:account:view', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify'],
|
||||||
|
线索跟进: ['leads:referral:view', 'leads:referral:advance'],
|
||||||
|
预约管理: ['courses:trial:view'],
|
||||||
|
到课确认: ['leads:referral:view', 'courses:trial:view'],
|
||||||
|
课消记录: ['courses:trial:view', 'statistics:channel:view'],
|
||||||
|
订单核对: ['statistics:channel:view'],
|
||||||
|
积分冲正: ['points:account:view', 'points:account:adjust'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissions(permissions = []) {
|
||||||
|
const normalized = permissions.flatMap((item) => LEGACY_PERMISSION_MAP[item] || item)
|
||||||
|
return sanitizePermissions(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(account, required = []) {
|
||||||
|
const permissions = account?.permissions || []
|
||||||
|
if (!required.length) return true
|
||||||
|
if (permissions.includes('*:*:*')) return true
|
||||||
|
return required.some((item) => permissions.includes(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePermissions(permissions = []) {
|
||||||
|
const allowed = new Set(['*:*:*', ...ALL_PERMISSIONS])
|
||||||
|
return [...new Set(permissions.filter((item) => allowed.has(item)))]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ALL_PERMISSIONS,
|
||||||
|
PERMISSION_TREE,
|
||||||
|
ROLE_PERMISSIONS,
|
||||||
|
hasPermission,
|
||||||
|
normalizePermissions,
|
||||||
|
sanitizePermissions,
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
const { adminService } = require('../services')
|
||||||
|
|
||||||
|
async function getDashboard(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.getDashboard() })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLeads(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.listLeads(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLeadStage(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.updateLeadStage(req.params.id, req.body.stage) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmLeadPurchase(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.confirmLeadPurchase(req.params.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPcOverview(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.getPcOverview(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccounts(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.getAccounts(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoles(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.getRoles() })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRolePermissions(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.updateRolePermissions(req.params.role, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAccount(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ ok: true, data: await adminService.createAccount(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAccount(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.updateAccount(req.params.id, req.body, req.operator) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.deleteAccount(req.params.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAccountRole(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.updateAccountRole(req.params.id, req.body.role) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAccountStatus(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await adminService.updateAccountStatus(req.params.id, req.body.status) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDashboard,
|
||||||
|
getLeads,
|
||||||
|
confirmLeadPurchase,
|
||||||
|
updateLeadStage,
|
||||||
|
getPcOverview,
|
||||||
|
getAccounts,
|
||||||
|
getRoles,
|
||||||
|
createAccount,
|
||||||
|
updateAccount,
|
||||||
|
deleteAccount,
|
||||||
|
updateAccountRole,
|
||||||
|
updateAccountStatus,
|
||||||
|
updateRolePermissions,
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
const { authService } = require('../services')
|
||||||
|
|
||||||
|
async function login(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.login(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function phoneLogin(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.loginByPhone(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSmsCode(req, res, next) {
|
||||||
|
try {
|
||||||
|
const data = await authService.sendSmsCode(req.body)
|
||||||
|
res.json({ ok: true, data, message: data.message || '验证码已发送' })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wechatLogin(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.loginByWechat(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindWechatPhone(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.bindWechatPhone(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.updateProfile(req.account.id, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.updatePassword(req.account.id, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePhone(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.updatePhone(req.account.id, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function current(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await authService.current(req.account.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
login,
|
||||||
|
phoneLogin,
|
||||||
|
sendSmsCode,
|
||||||
|
wechatLogin,
|
||||||
|
bindWechatPhone,
|
||||||
|
updateProfile,
|
||||||
|
updatePassword,
|
||||||
|
updatePhone,
|
||||||
|
current,
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
const { adminService, courseService, referralService, repository } = require('../services')
|
||||||
|
const { hasPermission } = require('../constants/permissions')
|
||||||
|
|
||||||
|
async function getBootstrap(req, res, next) {
|
||||||
|
try {
|
||||||
|
const profile = await repository.findUserById(req.account.id)
|
||||||
|
if (!profile) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const safeProfile = await repository.withPermissions(profile)
|
||||||
|
const account = await repository.getPointAccount(profile.id)
|
||||||
|
const referralCenter = await referralService.getCenter(profile.id)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
profile: { ...safeProfile, points: account },
|
||||||
|
points: account,
|
||||||
|
stats: referralCenter.stats,
|
||||||
|
referralStats: referralCenter.stats,
|
||||||
|
rules: referralCenter.rules,
|
||||||
|
courses: await courseService.listCourses(),
|
||||||
|
admin: hasPermission(safeProfile, ['dashboard:overview:view']) ? await adminService.getDashboard() : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getBootstrap }
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
const { courseService } = require('../services')
|
||||||
|
|
||||||
|
async function listCourses(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.listCourses(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAdminCourses(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.listAdminCourses(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminCourse(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.getAdminCourse(req.params.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAppointments(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.listAppointments(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUserAppointments(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.listUserAppointments(req.account.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attendAppointment(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.attendAppointment(req.params.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCourse(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ ok: true, data: await courseService.createCourse(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCourse(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.updateCourse(req.params.id, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCourse(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await courseService.deleteCourse(req.params.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bookCourse(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ ok: true, data: await courseService.book({ ...req.body, userId: req.account.id }) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listCourses,
|
||||||
|
listAdminCourses,
|
||||||
|
listAppointments,
|
||||||
|
listUserAppointments,
|
||||||
|
attendAppointment,
|
||||||
|
bookCourse,
|
||||||
|
createCourse,
|
||||||
|
deleteCourse,
|
||||||
|
getAdminCourse,
|
||||||
|
updateCourse,
|
||||||
|
}
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
const { pointService } = require('../services')
|
||||||
|
|
||||||
|
async function getPointCenter(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.getCenter(req.account.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redeemReward(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ ok: true, data: await pointService.redeem(req.account.id, req.body.itemId) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUserRewards(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listUserRewards(req.account.id, req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUserTransactions(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listUserTransactions(req.account.id, req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUserRedemptions(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listUserRedemptions(req.account.id, req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminPointManager(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.getAdminManager() })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPointAccounts(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listPointAccounts(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPointTransactions(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listPointTransactions(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adjustPoints(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({
|
||||||
|
ok: true,
|
||||||
|
data: await pointService.adjust(req.body.userId, req.body.amount, req.body.title, req.body.status),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settlePendingPoints(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: await pointService.settlePending(req.body.userId, req.body.amount, req.body.title),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateReward(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.updateReward(req.params.id, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateReferralRule(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.updateReferralRule(req.params.event, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReward(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ ok: true, data: await pointService.createReward(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRewards(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listRewards(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRewardDetail(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.getRewardDetail(req.params.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRedemptions(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.listRedemptions(req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveRedemption(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.approveRedemption(req.params.id, req.operator?.id || req.body.operatorId, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectRedemption(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.rejectRedemption(req.params.id, req.operator?.id || req.body.operatorId, req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyVoucher(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await pointService.verifyVoucher({
|
||||||
|
...req.body,
|
||||||
|
operatorId: req.operator?.id || req.body.operatorId,
|
||||||
|
}) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
adjustPoints,
|
||||||
|
approveRedemption,
|
||||||
|
createReward,
|
||||||
|
getAdminPointManager,
|
||||||
|
getPointCenter,
|
||||||
|
getRewardDetail,
|
||||||
|
listPointAccounts,
|
||||||
|
listPointTransactions,
|
||||||
|
listRedemptions,
|
||||||
|
listRewards,
|
||||||
|
listUserRedemptions,
|
||||||
|
listUserRewards,
|
||||||
|
listUserTransactions,
|
||||||
|
redeemReward,
|
||||||
|
rejectRedemption,
|
||||||
|
settlePendingPoints,
|
||||||
|
updateReward,
|
||||||
|
updateReferralRule,
|
||||||
|
verifyVoucher,
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
const { referralService } = require('../services')
|
||||||
|
|
||||||
|
async function getReferralCenter(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await referralService.getCenter(req.account.id, req.query) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createShareMaterial(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: { share: await referralService.createShare(req.body.channel || '默认', req.account.id) } })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMiniProgramCode(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, data: await referralService.createMiniProgramCode(req.account.id) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindReferral(req, res, next) {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ ok: true, data: await referralService.bindReferral(req.body) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getReferralCenter,
|
||||||
|
createShareMaterial,
|
||||||
|
createMiniProgramCode,
|
||||||
|
bindReferral,
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { createId } = require('../utils/ids')
|
||||||
|
const { env } = require('../config/env')
|
||||||
|
|
||||||
|
const MAX_AVATAR_SIZE = 2 * 1024 * 1024
|
||||||
|
const AVATAR_DIR = path.resolve(__dirname, '../../uploads/avatars')
|
||||||
|
const MIME_EXTENSIONS = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAvatarDir() {
|
||||||
|
fs.mkdirSync(AVATAR_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRequest(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = []
|
||||||
|
let size = 0
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > MAX_AVATAR_SIZE + 1024 * 1024) {
|
||||||
|
reject(Object.assign(new Error('头像文件不能超过 2MB'), { status: 413 }))
|
||||||
|
req.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultipart(buffer, contentType) {
|
||||||
|
const boundaryMatch = String(contentType || '').match(/boundary=(?:"([^"]+)"|([^;]+))/i)
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
const error = new Error('上传格式不正确')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const boundary = Buffer.from(`--${boundaryMatch[1] || boundaryMatch[2]}`)
|
||||||
|
let cursor = buffer.indexOf(boundary)
|
||||||
|
while (cursor >= 0) {
|
||||||
|
const next = buffer.indexOf(boundary, cursor + boundary.length)
|
||||||
|
if (next < 0) break
|
||||||
|
const part = buffer.subarray(cursor + boundary.length + 2, next - 2)
|
||||||
|
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
|
||||||
|
if (headerEnd > 0) {
|
||||||
|
const header = part.subarray(0, headerEnd).toString('utf8')
|
||||||
|
const body = part.subarray(headerEnd + 4)
|
||||||
|
const fileNameMatch = header.match(/filename="([^"]+)"/i)
|
||||||
|
const contentTypeMatch = header.match(/Content-Type:\s*([^\r\n]+)/i)
|
||||||
|
if (fileNameMatch && contentTypeMatch) {
|
||||||
|
return {
|
||||||
|
filename: fileNameMatch[1],
|
||||||
|
mime: contentTypeMatch[1].trim().toLowerCase(),
|
||||||
|
data: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
const error = new Error('没有找到上传文件')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(req, res, next) {
|
||||||
|
try {
|
||||||
|
const buffer = await collectRequest(req)
|
||||||
|
const file = parseMultipart(buffer, req.headers['content-type'])
|
||||||
|
const extension = MIME_EXTENSIONS[file.mime]
|
||||||
|
if (!extension) {
|
||||||
|
const error = new Error('仅支持 JPG、PNG、WEBP、GIF 图片')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!file.data.length || file.data.length > MAX_AVATAR_SIZE) {
|
||||||
|
const error = new Error('头像文件不能超过 2MB')
|
||||||
|
error.status = 413
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAvatarDir()
|
||||||
|
const filename = `${createId('avatar')}.${extension}`
|
||||||
|
const absolutePath = path.join(AVATAR_DIR, filename)
|
||||||
|
fs.writeFileSync(absolutePath, file.data)
|
||||||
|
|
||||||
|
const pathUrl = `/uploads/avatars/${filename}`
|
||||||
|
const baseUrl = env.publicBaseUrl || `${req.protocol}://${req.get('host')}`
|
||||||
|
res.status(201).json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
filename,
|
||||||
|
path: pathUrl,
|
||||||
|
url: `${baseUrl}${pathUrl}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { uploadAvatar }
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
function errorHandler(error, req, res, next) {
|
||||||
|
if (res.headersSent) {
|
||||||
|
next(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error.status || 500
|
||||||
|
res.status(status).json({
|
||||||
|
ok: false,
|
||||||
|
message: error.message || '服务器异常',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { errorHandler }
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
const { hasPermission } = require('../constants/permissions')
|
||||||
|
const { authenticateRequest } = require('./session')
|
||||||
|
|
||||||
|
function requirePermission(...required) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const account = await authenticateRequest(req)
|
||||||
|
if (!hasPermission(account, required)) {
|
||||||
|
const error = new Error('没有操作权限')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
req.operator = account
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requirePermission }
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
const { repository } = require('../repositories')
|
||||||
|
const { verifySessionToken } = require('../utils/sessionToken')
|
||||||
|
|
||||||
|
function readSessionToken(req) {
|
||||||
|
const authorization = String(req.get('authorization') || '').trim()
|
||||||
|
if (authorization.toLowerCase().startsWith('bearer ')) return authorization.slice(7).trim()
|
||||||
|
return String(req.get('x-session-token') || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateRequest(req) {
|
||||||
|
if (req.account) return req.account
|
||||||
|
const payload = verifySessionToken(readSessionToken(req))
|
||||||
|
if (!payload) {
|
||||||
|
const error = new Error('登录状态无效或已过期,请重新登录')
|
||||||
|
error.status = 401
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const account = await repository.withPermissions(await repository.findUserById(payload.sub))
|
||||||
|
if (!account || account.status !== 'active') {
|
||||||
|
const error = new Error('账号不存在或已停用')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
req.account = account
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSession(req, res, next) {
|
||||||
|
try {
|
||||||
|
await authenticateRequest(req)
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSelf(source, key) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const targetId = String(req[source]?.[key] || '').trim()
|
||||||
|
if (targetId && targetId !== req.account?.id) {
|
||||||
|
const error = new Error('不能访问或修改其他用户的数据')
|
||||||
|
error.status = 403
|
||||||
|
next(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authenticateRequest, requireSelf, requireSession }
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
const { env } = require('../config/env')
|
||||||
|
const { repository: memoryRepository } = require('./memoryRepository')
|
||||||
|
const { MySqlRepository } = require('./mysqlRepository')
|
||||||
|
|
||||||
|
const repository = env.dbClient === 'mysql' ? new MySqlRepository() : memoryRepository
|
||||||
|
|
||||||
|
module.exports = { repository }
|
||||||
@ -0,0 +1,549 @@
|
|||||||
|
const { seed } = require('../data/seed')
|
||||||
|
const { hashPassword } = require('../utils/password')
|
||||||
|
const { normalizePermissions } = require('../constants/permissions')
|
||||||
|
|
||||||
|
class MemoryRepository {
|
||||||
|
constructor(initialState) {
|
||||||
|
this.state = structuredClone(initialState)
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserById(id) {
|
||||||
|
return this.state.users.find((user) => user.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserByInviteCode(inviterCode) {
|
||||||
|
return this.state.users.find((user) => user.inviterCode === inviterCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserByPhone(phone) {
|
||||||
|
return this.state.users.find((user) => user.phone === phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserByLoginAccount(account) {
|
||||||
|
const value = String(account || '').trim()
|
||||||
|
return this.state.users.find((user) => [user.username, user.phone, user.id, user.wechatOpenid, user.wechatUnionid].filter(Boolean).includes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
listAccounts() {
|
||||||
|
return this.state.users
|
||||||
|
.map((user) => this.withPermissions(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
listParentUsers() {
|
||||||
|
return this.state.users
|
||||||
|
.filter((user) => user.role === 'parent')
|
||||||
|
.map((user) => this.withPermissions(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoleMatrix() {
|
||||||
|
return this.state.roles.filter((role) => role.role !== 'parent').map((role) => ({
|
||||||
|
...role,
|
||||||
|
permissions: normalizePermissions(role.permissions || []),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
findRole(roleName) {
|
||||||
|
return this.state.roles.find((item) => item.role === roleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRolePermissions(roleName, permissions) {
|
||||||
|
const role = this.findRole(roleName)
|
||||||
|
if (!role) return null
|
||||||
|
role.permissions = [...(permissions || [])]
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
withPermissions(user) {
|
||||||
|
const role = this.findRole(user.role)
|
||||||
|
const hasPassword = Boolean(user.passwordHash)
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
trialBookingLimit: Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1,
|
||||||
|
passwordHash: undefined,
|
||||||
|
hasPassword,
|
||||||
|
roleName: role?.name || user.roleName || user.role,
|
||||||
|
permissions: user.role === 'parent' ? [] : normalizePermissions(role?.permissions || []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touchLogin(id) {
|
||||||
|
const user = this.findUserById(id)
|
||||||
|
if (!user) return null
|
||||||
|
user.lastLoginAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||||
|
return this.withPermissions(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
addUser(user) {
|
||||||
|
if (!user.username) user.username = user.phone || user.id
|
||||||
|
if (!user.passwordHash && user.password) user.passwordHash = hashPassword(user.password)
|
||||||
|
if (user.passwordHash === undefined) user.passwordHash = null
|
||||||
|
user.phone = user.phone || null
|
||||||
|
user.wechatOpenid = user.wechatOpenid || null
|
||||||
|
user.wechatUnionid = user.wechatUnionid || null
|
||||||
|
user.wechatNickname = user.wechatNickname || null
|
||||||
|
user.wechatAvatar = user.wechatAvatar || null
|
||||||
|
user.loginProvider = user.loginProvider || (user.wechatOpenid ? 'wechat' : 'password')
|
||||||
|
user.phoneBound = user.phoneBound === undefined ? Boolean(user.phone) : Boolean(user.phoneBound)
|
||||||
|
user.phoneBoundAt = user.phoneBoundAt || (user.phone ? new Date().toISOString().slice(0, 16).replace('T', ' ') : null)
|
||||||
|
user.wechatBoundAt = user.wechatBoundAt || (user.wechatOpenid ? new Date().toISOString().slice(0, 16).replace('T', ' ') : null)
|
||||||
|
user.trialBookingLimit = Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1
|
||||||
|
user.createdAt = user.createdAt || new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||||
|
delete user.password
|
||||||
|
this.state.users.push(user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(id, patch) {
|
||||||
|
const user = this.findUserById(id)
|
||||||
|
if (!user) return null
|
||||||
|
if (patch.passwordHash !== undefined) user.passwordHash = patch.passwordHash
|
||||||
|
if (patch.username !== undefined) user.username = patch.username
|
||||||
|
if (patch.role !== undefined) user.role = patch.role
|
||||||
|
if (patch.roleName !== undefined) user.roleName = patch.roleName
|
||||||
|
if (patch.name !== undefined) user.name = patch.name
|
||||||
|
if (patch.phone !== undefined) user.phone = patch.phone || null
|
||||||
|
if (patch.wechatOpenid !== undefined) user.wechatOpenid = patch.wechatOpenid || null
|
||||||
|
if (patch.wechatUnionid !== undefined) user.wechatUnionid = patch.wechatUnionid || null
|
||||||
|
if (patch.wechatNickname !== undefined) user.wechatNickname = patch.wechatNickname || null
|
||||||
|
if (patch.wechatAvatar !== undefined) user.wechatAvatar = patch.wechatAvatar || null
|
||||||
|
if (patch.loginProvider !== undefined) user.loginProvider = patch.loginProvider
|
||||||
|
if (patch.phoneBound !== undefined) user.phoneBound = Boolean(patch.phoneBound)
|
||||||
|
if (patch.phoneBoundAt !== undefined) user.phoneBoundAt = patch.phoneBoundAt
|
||||||
|
if (patch.wechatBoundAt !== undefined) user.wechatBoundAt = patch.wechatBoundAt
|
||||||
|
if (patch.status !== undefined) user.status = patch.status
|
||||||
|
if (patch.inviterCode !== undefined) user.inviterCode = patch.inviterCode
|
||||||
|
if (patch.trialBookingLimit !== undefined) user.trialBookingLimit = Number(patch.trialBookingLimit)
|
||||||
|
if (patch.child !== undefined) user.child = patch.child
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteUser(id) {
|
||||||
|
const index = this.state.users.findIndex((user) => user.id === id)
|
||||||
|
if (index < 0) return null
|
||||||
|
const [user] = this.state.users.splice(index, 1)
|
||||||
|
this.state.pointAccounts = this.state.pointAccounts.filter((item) => item.userId !== id)
|
||||||
|
this.state.pointTransactions = this.state.pointTransactions.filter((item) => item.userId !== id)
|
||||||
|
this.state.referrals = this.state.referrals.filter((item) => item.inviterId !== id && item.inviteeId !== id)
|
||||||
|
this.state.appointments = this.state.appointments.filter((item) => item.userId !== id)
|
||||||
|
this.state.redemptionOrders = this.state.redemptionOrders.filter((item) => item.userId !== id)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAccountRole(id, roleName) {
|
||||||
|
const user = this.findUserById(id)
|
||||||
|
const role = this.findRole(roleName)
|
||||||
|
if (!user || !role) return null
|
||||||
|
user.role = role.role
|
||||||
|
user.roleName = role.name
|
||||||
|
return this.withPermissions(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAccountStatus(id, status) {
|
||||||
|
const user = this.findUserById(id)
|
||||||
|
if (!user) return null
|
||||||
|
user.status = status
|
||||||
|
return this.withPermissions(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPointAccount(userId) {
|
||||||
|
let account = this.state.pointAccounts.find((item) => item.userId === userId)
|
||||||
|
if (!account) {
|
||||||
|
account = { userId, available: 0, pending: 0, frozen: 0, expiring: 0 }
|
||||||
|
this.state.pointAccounts.push(account)
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePointAccount(userId, patch) {
|
||||||
|
const account = this.getPointAccount(userId)
|
||||||
|
Object.assign(account, patch)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointTransaction(transaction) {
|
||||||
|
this.state.pointTransactions.unshift(transaction)
|
||||||
|
return transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
listPointTransactions() {
|
||||||
|
return this.state.pointTransactions
|
||||||
|
}
|
||||||
|
|
||||||
|
getPointTransactions(userId) {
|
||||||
|
return this.state.pointTransactions.filter((item) => item.userId === userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPointExpiryRule() {
|
||||||
|
return this.state.pointExpiryRule
|
||||||
|
}
|
||||||
|
|
||||||
|
listPointExpirySchedules(userId) {
|
||||||
|
return this.state.pointExpirySchedules.filter((item) => !userId || item.userId === userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getReferralRules() {
|
||||||
|
return this.state.referralRules
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReferralRule(event, patch) {
|
||||||
|
const rule = this.state.referralRules.find((item) => item.event === event)
|
||||||
|
if (!rule) return null
|
||||||
|
Object.assign(rule, patch)
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
getReferralsByInviter(inviterId) {
|
||||||
|
return this.state.referrals.filter((item) => item.inviterId === inviterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
findReferralByInvitee(inviteeId) {
|
||||||
|
return this.state.referrals.find((item) => item.inviteeId === inviteeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
findReferralById(id) {
|
||||||
|
return this.state.referrals.find((item) => item.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
findReferralForLead(lead = {}) {
|
||||||
|
if (lead.referralId) {
|
||||||
|
const referral = this.findReferralById(lead.referralId)
|
||||||
|
if (referral) return referral
|
||||||
|
}
|
||||||
|
if (lead.inviteeId) {
|
||||||
|
const referral = this.findReferralByInvitee(lead.inviteeId)
|
||||||
|
if (referral) return referral
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = String(lead.parent || '').trim()
|
||||||
|
if (!parent) return null
|
||||||
|
const source = String(lead.source || '').trim()
|
||||||
|
const channel = String(lead.channel || '').trim()
|
||||||
|
const candidates = this.state.referrals
|
||||||
|
.map((referral) => {
|
||||||
|
const inviter = this.findUserById(referral.inviterId)
|
||||||
|
const invitee = referral.inviteeId ? this.findUserById(referral.inviteeId) : null
|
||||||
|
const fallbackNames = [
|
||||||
|
{ value: invitee?.name, score: 40 },
|
||||||
|
{ value: referral.name, score: 30 },
|
||||||
|
{ value: invitee?.phone ? `家长${String(invitee.phone).slice(-4)}` : '', score: 20 },
|
||||||
|
{ value: referral.phone ? `家长${String(referral.phone).slice(-4)}` : '', score: 20 },
|
||||||
|
].filter((item) => item.value)
|
||||||
|
const parentHit = fallbackNames.find((item) => item.value === parent)
|
||||||
|
if (!parentHit) return null
|
||||||
|
const sourceValues = [inviter?.name, inviter?.id, inviter?.phone].filter(Boolean)
|
||||||
|
const sourceScore = !source || sourceValues.includes(source) ? 8 : 0
|
||||||
|
const channelScore = !channel || !referral.channel || referral.channel === channel ? 4 : 0
|
||||||
|
return { referral, score: parentHit.score + sourceScore + channelScore }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
|
||||||
|
return candidates[0]?.referral || null
|
||||||
|
}
|
||||||
|
|
||||||
|
addReferral(referral) {
|
||||||
|
this.state.referrals.unshift(referral)
|
||||||
|
return referral
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReferral(id, patch) {
|
||||||
|
const referral = this.state.referrals.find((item) => item.id === id)
|
||||||
|
if (!referral) return null
|
||||||
|
Object.assign(referral, patch)
|
||||||
|
return referral
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReferralInviteeInfo(inviteeId, patch = {}) {
|
||||||
|
const updated = []
|
||||||
|
this.state.referrals.forEach((referral) => {
|
||||||
|
if (referral.inviteeId !== inviteeId) return
|
||||||
|
if (patch.name !== undefined) referral.name = patch.name
|
||||||
|
if (patch.phone !== undefined) referral.phone = patch.phone
|
||||||
|
if (patch.childGrade !== undefined) referral.childGrade = patch.childGrade
|
||||||
|
updated.push(referral)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
getCourses() {
|
||||||
|
return this.state.courses
|
||||||
|
}
|
||||||
|
|
||||||
|
findCourse(id) {
|
||||||
|
return this.state.courses.find((course) => course.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
addCourse(course) {
|
||||||
|
this.state.courses.unshift(course)
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCourse(id, patch) {
|
||||||
|
const course = this.findCourse(id)
|
||||||
|
if (!course) return null
|
||||||
|
Object.assign(course, patch)
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCourse(id) {
|
||||||
|
const index = this.state.courses.findIndex((course) => course.id === id)
|
||||||
|
if (index < 0) return null
|
||||||
|
const [course] = this.state.courses.splice(index, 1)
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
|
addAppointment(appointment) {
|
||||||
|
this.state.appointments.unshift(appointment)
|
||||||
|
return appointment
|
||||||
|
}
|
||||||
|
|
||||||
|
findAppointmentById(id) {
|
||||||
|
return this.decorateAppointment(this.state.appointments.find((appointment) => appointment.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAppointment(id, patch) {
|
||||||
|
const appointment = this.state.appointments.find((item) => item.id === id)
|
||||||
|
if (!appointment) return null
|
||||||
|
Object.assign(appointment, patch)
|
||||||
|
return this.decorateAppointment(appointment)
|
||||||
|
}
|
||||||
|
|
||||||
|
decorateAppointment(appointment) {
|
||||||
|
if (!appointment) return null
|
||||||
|
const user = this.findUserById(appointment.userId)
|
||||||
|
const course = this.findCourse(appointment.courseId)
|
||||||
|
return {
|
||||||
|
...appointment,
|
||||||
|
user: user
|
||||||
|
? {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
course: course
|
||||||
|
? {
|
||||||
|
id: course.id,
|
||||||
|
name: course.name,
|
||||||
|
campus: course.campus,
|
||||||
|
teacher: course.teacher,
|
||||||
|
nextTime: course.nextTime,
|
||||||
|
pointsBack: Number(course.pointsBack || 0),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findAppointmentByUserId(userId) {
|
||||||
|
return this.decorateAppointment(this.state.appointments.find((appointment) => appointment.userId === userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
listAppointments(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
return this.state.appointments
|
||||||
|
.map((appointment) => this.decorateAppointment(appointment))
|
||||||
|
.filter((appointment) => {
|
||||||
|
const matchUser = !filters.userId || appointment.userId === filters.userId
|
||||||
|
const matchCourse = !filters.courseId || appointment.courseId === filters.courseId
|
||||||
|
const matchStatus = !filters.status || filters.status === 'all' || appointment.status === filters.status
|
||||||
|
const matchKeyword = !keyword || [appointment.user?.name, appointment.user?.phone, appointment.course?.name, appointment.course?.campus, appointment.course?.teacher].some((value) => String(value || '').includes(keyword))
|
||||||
|
return matchUser && matchCourse && matchStatus && matchKeyword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getRewards() {
|
||||||
|
return this.state.rewards
|
||||||
|
}
|
||||||
|
|
||||||
|
listRewards(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
return this.state.rewards.filter((reward) => {
|
||||||
|
const matchKeyword = !keyword || [reward.name, reward.type, reward.description].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchType = !filters.type || filters.type === 'all' || reward.type === filters.type
|
||||||
|
const matchStock = !filters.stockStatus
|
||||||
|
|| filters.stockStatus === 'all'
|
||||||
|
|| (filters.stockStatus === 'in_stock' && Number(reward.stock || 0) > 0)
|
||||||
|
|| (filters.stockStatus === 'sold_out' && Number(reward.stock || 0) <= 0)
|
||||||
|
return matchKeyword && matchType && matchStock
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
findReward(id) {
|
||||||
|
return this.state.rewards.find((reward) => reward.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
addReward(reward) {
|
||||||
|
this.state.rewards.unshift(reward)
|
||||||
|
return reward
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReward(id, patch) {
|
||||||
|
const reward = this.findReward(id)
|
||||||
|
if (!reward) return null
|
||||||
|
Object.assign(reward, patch)
|
||||||
|
return reward
|
||||||
|
}
|
||||||
|
|
||||||
|
decorateRedemption(order) {
|
||||||
|
if (!order) return null
|
||||||
|
const user = this.findUserById(order.userId)
|
||||||
|
const reward = this.findReward(order.itemId)
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
costPoints: Number(order.costPoints || reward?.cost || 0),
|
||||||
|
voucherCode: order.voucherCode || '',
|
||||||
|
voucherStatus: order.voucherStatus || 'not_issued',
|
||||||
|
reviewedBy: order.reviewedBy || '',
|
||||||
|
reviewedAt: order.reviewedAt || '',
|
||||||
|
rejectedReason: order.rejectedReason || '',
|
||||||
|
expireAt: order.expireAt || '',
|
||||||
|
usedAt: order.usedAt || '',
|
||||||
|
usedBy: order.usedBy || '',
|
||||||
|
verifyNote: order.verifyNote || '',
|
||||||
|
user: user
|
||||||
|
? {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
reward: reward
|
||||||
|
? {
|
||||||
|
id: reward.id,
|
||||||
|
name: reward.name,
|
||||||
|
type: reward.type,
|
||||||
|
cost: Number(reward.cost || 0),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addRedemption(order) {
|
||||||
|
const next = {
|
||||||
|
...order,
|
||||||
|
costPoints: Number(order.costPoints || 0),
|
||||||
|
voucherCode: order.voucherCode || '',
|
||||||
|
voucherStatus: order.voucherStatus || 'not_issued',
|
||||||
|
reviewedBy: order.reviewedBy || '',
|
||||||
|
reviewedAt: order.reviewedAt || '',
|
||||||
|
rejectedReason: order.rejectedReason || '',
|
||||||
|
expireAt: order.expireAt || '',
|
||||||
|
usedAt: order.usedAt || '',
|
||||||
|
usedBy: order.usedBy || '',
|
||||||
|
verifyNote: order.verifyNote || '',
|
||||||
|
}
|
||||||
|
this.state.redemptionOrders.unshift(next)
|
||||||
|
return this.decorateRedemption(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
findRedemptionById(id) {
|
||||||
|
return this.decorateRedemption(this.state.redemptionOrders.find((order) => order.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
findRedemptionByVoucherCode(voucherCode) {
|
||||||
|
return this.decorateRedemption(this.state.redemptionOrders.find((order) => order.voucherCode === voucherCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
listRedemptions(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
return this.state.redemptionOrders
|
||||||
|
.map((order) => this.decorateRedemption(order))
|
||||||
|
.filter((order) => {
|
||||||
|
if (!order) return false
|
||||||
|
const matchUser = !filters.userId || order.userId === filters.userId
|
||||||
|
const matchItem = !filters.itemId || order.itemId === filters.itemId
|
||||||
|
const matchStatus = !filters.status || filters.status === 'all' || order.status === filters.status
|
||||||
|
const matchVoucherStatus = !filters.voucherStatus || filters.voucherStatus === 'all' || order.voucherStatus === filters.voucherStatus
|
||||||
|
const matchType = !filters.type || filters.type === 'all' || order.reward?.type === filters.type
|
||||||
|
const matchKeyword = !keyword || [order.id, order.voucherCode, order.user?.name, order.user?.phone, order.reward?.name].some((value) => String(value || '').includes(keyword))
|
||||||
|
return matchUser && matchItem && matchStatus && matchVoucherStatus && matchType && matchKeyword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRedemption(id, patch) {
|
||||||
|
const order = this.state.redemptionOrders.find((item) => item.id === id)
|
||||||
|
if (!order) return null
|
||||||
|
Object.assign(order, patch)
|
||||||
|
return this.decorateRedemption(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeads() {
|
||||||
|
return this.state.leads
|
||||||
|
}
|
||||||
|
|
||||||
|
getChannelStats() {
|
||||||
|
return this.state.channelStats
|
||||||
|
}
|
||||||
|
|
||||||
|
addLead(lead) {
|
||||||
|
const next = {
|
||||||
|
referralId: '',
|
||||||
|
inviteeId: '',
|
||||||
|
createdAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
...lead,
|
||||||
|
}
|
||||||
|
this.state.leads.unshift(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLeadStage(id, stage) {
|
||||||
|
const lead = this.state.leads.find((item) => item.id === id)
|
||||||
|
if (!lead) return null
|
||||||
|
lead.stage = stage
|
||||||
|
return lead
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLeadsByParent(parent, patch = {}) {
|
||||||
|
const updated = []
|
||||||
|
this.state.leads.forEach((lead) => {
|
||||||
|
if (lead.parent !== parent) return
|
||||||
|
Object.assign(lead, patch)
|
||||||
|
updated.push(lead)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
findLeadForReferral(referral, previous = {}) {
|
||||||
|
if (!referral) return null
|
||||||
|
const inviter = this.findUserById(referral.inviterId)
|
||||||
|
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)
|
||||||
|
|
||||||
|
const exactLead = this.state.leads.find((lead) => referral.id && lead.referralId === referral.id)
|
||||||
|
|| this.state.leads.find((lead) => referral.inviteeId && lead.inviteeId === referral.inviteeId)
|
||||||
|
if (exactLead) return exactLead
|
||||||
|
|
||||||
|
const candidates = this.state.leads
|
||||||
|
.map((lead) => {
|
||||||
|
if (!fallbackNames.includes(lead.parent)) return null
|
||||||
|
const sourceScore = !lead.source || sources.includes(lead.source) ? 8 : 0
|
||||||
|
const channelScore = !referral.channel || !lead.channel || lead.channel === referral.channel ? 4 : 0
|
||||||
|
return { lead, score: sourceScore + channelScore }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
return candidates[0]?.lead || null
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLeadInfoByReferral(referral, patch = {}, previous = {}) {
|
||||||
|
const lead = this.findLeadForReferral(referral, previous)
|
||||||
|
if (!lead) return []
|
||||||
|
Object.assign(lead, patch)
|
||||||
|
if (referral.id && !lead.referralId) lead.referralId = referral.id
|
||||||
|
if (referral.inviteeId && !lead.inviteeId) lead.inviteeId = referral.inviteeId
|
||||||
|
return [lead]
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLeadStageByReferral(referral, stage) {
|
||||||
|
return this.updateLeadInfoByReferral(referral, { stage })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = new MemoryRepository(seed)
|
||||||
|
|
||||||
|
module.exports = { repository }
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const {
|
||||||
|
getAccounts,
|
||||||
|
createAccount,
|
||||||
|
deleteAccount,
|
||||||
|
getDashboard,
|
||||||
|
getLeads,
|
||||||
|
confirmLeadPurchase,
|
||||||
|
getPcOverview,
|
||||||
|
getRoles,
|
||||||
|
updateAccount,
|
||||||
|
updateAccountRole,
|
||||||
|
updateAccountStatus,
|
||||||
|
updateLeadStage,
|
||||||
|
updateRolePermissions,
|
||||||
|
} = require('../controllers/adminController')
|
||||||
|
const { bindWechatPhone, current, login, phoneLogin, sendSmsCode, updatePassword, updatePhone, updateProfile, wechatLogin } = require('../controllers/authController')
|
||||||
|
const { getBootstrap } = require('../controllers/bootstrapController')
|
||||||
|
const { attendAppointment, bookCourse, createCourse, deleteCourse, getAdminCourse, listAdminCourses, listAppointments, listCourses, listUserAppointments, updateCourse } = require('../controllers/courseController')
|
||||||
|
const {
|
||||||
|
adjustPoints,
|
||||||
|
approveRedemption,
|
||||||
|
createReward,
|
||||||
|
getRewardDetail,
|
||||||
|
getAdminPointManager,
|
||||||
|
getPointCenter,
|
||||||
|
listPointAccounts,
|
||||||
|
listPointTransactions,
|
||||||
|
listRedemptions,
|
||||||
|
listRewards,
|
||||||
|
listUserRedemptions,
|
||||||
|
listUserRewards,
|
||||||
|
listUserTransactions,
|
||||||
|
redeemReward,
|
||||||
|
rejectRedemption,
|
||||||
|
settlePendingPoints,
|
||||||
|
updateReward,
|
||||||
|
updateReferralRule,
|
||||||
|
verifyVoucher,
|
||||||
|
} = require('../controllers/pointController')
|
||||||
|
const { bindReferral, createMiniProgramCode, createShareMaterial, getReferralCenter } = require('../controllers/referralController')
|
||||||
|
const { uploadAvatar } = require('../controllers/uploadController')
|
||||||
|
const { requirePermission } = require('../middleware/permission')
|
||||||
|
const { requireSelf, requireSession } = require('../middleware/session')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get('/bootstrap', requireSession, requireSelf('query', 'accountId'), getBootstrap)
|
||||||
|
|
||||||
|
router.get('/auth/current', requireSession, current)
|
||||||
|
router.post('/auth/login', login)
|
||||||
|
router.post('/auth/sms-code', sendSmsCode)
|
||||||
|
router.post('/auth/phone-login', phoneLogin)
|
||||||
|
router.post('/auth/wechat-login', wechatLogin)
|
||||||
|
router.post('/auth/wechat-bind-phone', bindWechatPhone)
|
||||||
|
router.patch('/auth/profile/:id', requireSession, requireSelf('params', 'id'), updateProfile)
|
||||||
|
router.patch('/auth/password/:id', requireSession, requireSelf('params', 'id'), updatePassword)
|
||||||
|
router.patch('/auth/phone/:id', requireSession, requireSelf('params', 'id'), updatePhone)
|
||||||
|
|
||||||
|
router.post('/uploads/avatar', requireSession, uploadAvatar)
|
||||||
|
|
||||||
|
router.get('/referrals', requireSession, requireSelf('query', 'inviterId'), getReferralCenter)
|
||||||
|
router.post('/referrals/share', requireSession, requireSelf('body', 'inviterId'), createShareMaterial)
|
||||||
|
router.post('/referrals/mini-code', requireSession, requireSelf('body', 'inviterId'), createMiniProgramCode)
|
||||||
|
router.post('/referrals/bind', bindReferral)
|
||||||
|
|
||||||
|
router.get('/points/:userId/rewards', requireSession, requireSelf('params', 'userId'), listUserRewards)
|
||||||
|
router.get('/points/:userId/transactions', requireSession, requireSelf('params', 'userId'), listUserTransactions)
|
||||||
|
router.get('/points/:userId/redemptions', requireSession, requireSelf('params', 'userId'), listUserRedemptions)
|
||||||
|
router.get('/points/:userId', requireSession, requireSelf('params', 'userId'), getPointCenter)
|
||||||
|
router.post('/redemptions', requireSession, requireSelf('body', 'userId'), redeemReward)
|
||||||
|
|
||||||
|
router.get('/courses', listCourses)
|
||||||
|
router.get('/appointments', requireSession, requireSelf('query', 'userId'), listUserAppointments)
|
||||||
|
router.post('/appointments', requireSession, requireSelf('body', 'userId'), bookCourse)
|
||||||
|
|
||||||
|
router.get('/admin/dashboard', requirePermission('dashboard:overview:view'), getDashboard)
|
||||||
|
router.get('/admin/leads', requirePermission('leads:referral:view'), getLeads)
|
||||||
|
router.post('/admin/leads/:id/purchase', requirePermission('leads:referral:advance'), confirmLeadPurchase)
|
||||||
|
router.get('/admin/courses', requirePermission('courses:trial:view'), listAdminCourses)
|
||||||
|
router.get('/admin/courses/:id', requirePermission('courses:trial:view'), getAdminCourse)
|
||||||
|
router.get('/admin/appointments', requirePermission('courses:trial:view'), listAppointments)
|
||||||
|
router.patch('/admin/appointments/:id/attend', requirePermission('courses:trial:edit'), attendAppointment)
|
||||||
|
router.post('/admin/courses', requirePermission('courses:trial:add'), createCourse)
|
||||||
|
router.patch('/admin/courses/:id', requirePermission('courses:trial:edit'), updateCourse)
|
||||||
|
router.delete('/admin/courses/:id', requirePermission('courses:trial:delete'), deleteCourse)
|
||||||
|
router.get('/admin/pc-overview', requirePermission('dashboard:overview:view', 'statistics:channel:view'), getPcOverview)
|
||||||
|
router.get('/admin/accounts', requirePermission('system:user:view'), getAccounts)
|
||||||
|
router.post('/admin/accounts', requirePermission('system:user:add'), createAccount)
|
||||||
|
router.patch('/admin/accounts/:id', requirePermission('system:user:edit'), updateAccount)
|
||||||
|
router.delete('/admin/accounts/:id', requirePermission('system:user:delete'), deleteAccount)
|
||||||
|
router.get('/admin/roles', requirePermission('system:role:view', 'system:user:view'), getRoles)
|
||||||
|
router.patch('/admin/roles/:role/permissions', requirePermission('system:role:edit'), updateRolePermissions)
|
||||||
|
router.get('/admin/points', requirePermission('points:account:view'), getAdminPointManager)
|
||||||
|
router.get('/admin/point-accounts', requirePermission('points:account:view'), listPointAccounts)
|
||||||
|
router.get('/admin/point-transactions', requirePermission('points:account:view'), listPointTransactions)
|
||||||
|
router.post('/admin/points/adjust', requirePermission('points:account:adjust'), adjustPoints)
|
||||||
|
router.post('/admin/points/settle-pending', requirePermission('points:account:adjust'), settlePendingPoints)
|
||||||
|
router.patch('/admin/referral-rules/:event', requirePermission('points:rule:edit', 'points:account:adjust'), updateReferralRule)
|
||||||
|
router.get('/admin/redemptions', requirePermission('points:redemption:view', 'points:voucher:verify'), listRedemptions)
|
||||||
|
router.patch('/admin/redemptions/:id/approve', requirePermission('points:redemption:review'), approveRedemption)
|
||||||
|
router.patch('/admin/redemptions/:id/reject', requirePermission('points:redemption:review'), rejectRedemption)
|
||||||
|
router.post('/admin/vouchers/verify', requirePermission('points:voucher:verify'), verifyVoucher)
|
||||||
|
router.patch('/admin/leads/:id/stage', requirePermission('leads:referral:advance'), updateLeadStage)
|
||||||
|
router.patch('/admin/accounts/:id/role', requirePermission('system:user:role'), updateAccountRole)
|
||||||
|
router.patch('/admin/accounts/:id/status', requirePermission('system:user:status'), updateAccountStatus)
|
||||||
|
router.get('/admin/rewards', requirePermission('points:reward:edit', 'points:redemption:view'), listRewards)
|
||||||
|
router.post('/admin/rewards', requirePermission('points:reward:edit'), createReward)
|
||||||
|
router.get('/admin/rewards/:id', requirePermission('points:reward:edit', 'points:redemption:view'), getRewardDetail)
|
||||||
|
router.patch('/admin/rewards/:id', requirePermission('points:reward:edit'), updateReward)
|
||||||
|
|
||||||
|
module.exports = { router }
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
const { app } = require('./app')
|
||||||
|
const { env } = require('./config/env')
|
||||||
|
|
||||||
|
app.listen(env.port, () => {
|
||||||
|
console.log(`LiFangHe Education API running at http://localhost:${env.port}/api`)
|
||||||
|
})
|
||||||
@ -0,0 +1,685 @@
|
|||||||
|
const { createId } = require('../utils/ids')
|
||||||
|
const { hashPassword } = require('../utils/password')
|
||||||
|
const { PERMISSION_TREE, hasPermission, sanitizePermissions } = require('../constants/permissions')
|
||||||
|
|
||||||
|
const VALID_ROLES = ['parent', 'super_admin', 'campus_admin', 'advisor', 'teacher', 'finance']
|
||||||
|
const VALID_STATUSES = ['active', 'disabled']
|
||||||
|
const LEAD_STAGE_FLOW = ['待跟进', '已注册', '已预约', '已到课', '已报名']
|
||||||
|
const REFERRAL_STAGE_FLOW = ['待注册', '已注册', '已预约', '已到课', '已报名']
|
||||||
|
const REFERRAL_STAGE_EVENTS = {
|
||||||
|
已注册: 'registered',
|
||||||
|
已预约: 'booked',
|
||||||
|
已到课: 'attended',
|
||||||
|
已报名: 'purchased',
|
||||||
|
}
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function formatDateKey(date) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateKey(value) {
|
||||||
|
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||||
|
return formatDateKey(date) === match[0] ? date : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDate(date, days) {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setDate(next.getDate() + days)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotAccount(account) {
|
||||||
|
if (!account) return null
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
child: account.child ? { ...account.child } : account.child,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate(list = [], filters = {}) {
|
||||||
|
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
|
||||||
|
const start = (pageNum - 1) * pageSize
|
||||||
|
return {
|
||||||
|
list: list.slice(start, start + pageSize),
|
||||||
|
total: list.length,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrialBookingLimit(value, fallback = 1) {
|
||||||
|
const limit = Number(value ?? fallback)
|
||||||
|
if (!Number.isInteger(limit) || limit < 0 || limit > 99) {
|
||||||
|
const error = new Error('可预约试听次数必须是 0 到 99 之间的整数')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminService {
|
||||||
|
constructor(repository, pointService) {
|
||||||
|
this.repository = repository
|
||||||
|
this.pointService = pointService
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDashboard() {
|
||||||
|
const leads = await this.getDecoratedLeads()
|
||||||
|
const transactions = await this.repository.listPointTransactions()
|
||||||
|
const overview = this.getLeadOverview(leads, transactions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview,
|
||||||
|
funnel: [
|
||||||
|
{ label: '触达', value: leads.length },
|
||||||
|
{ label: '注册', value: leads.filter((lead) => lead.stage !== '待跟进').length },
|
||||||
|
{ label: '预约', value: leads.filter((lead) => ['已预约', '已到课', '已报名'].includes(lead.stage)).length },
|
||||||
|
{ label: '到课', value: overview.attended },
|
||||||
|
{ label: '报名', value: overview.purchased },
|
||||||
|
],
|
||||||
|
leads,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeadOverview(leads, transactions) {
|
||||||
|
return {
|
||||||
|
leads: leads.length,
|
||||||
|
attended: leads.filter((lead) => ['已到课', '已报名'].includes(lead.stage)).length,
|
||||||
|
purchased: leads.filter((lead) => lead.stage === '已报名').length,
|
||||||
|
pointsCost: transactions
|
||||||
|
.filter((tx) => tx.status !== 'pending')
|
||||||
|
.reduce((sum, tx) => sum + Math.max(0, tx.amount), 0),
|
||||||
|
revenue: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLeads(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const stage = String(filters.stage || '').trim()
|
||||||
|
const advisor = String(filters.advisor || '').trim()
|
||||||
|
const leads = await this.getDecoratedLeads()
|
||||||
|
const list = leads.filter((lead) => {
|
||||||
|
const matchKeyword = !keyword || [lead.parent, lead.phone, lead.childGrade, lead.source, lead.channel].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchStage = !stage || stage === '全部' || lead.stage === stage
|
||||||
|
const matchAdvisor = !advisor || advisor === '全部' || lead.advisor === advisor
|
||||||
|
return matchKeyword && matchStage && matchAdvisor
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total: list.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPcOverview(filters = {}) {
|
||||||
|
const accounts = await this.repository.listAccounts()
|
||||||
|
const leads = await this.getDecoratedLeads()
|
||||||
|
const transactions = await this.repository.listPointTransactions()
|
||||||
|
const purchased = leads.filter((lead) => lead.stage === '已报名').length
|
||||||
|
const totalLeads = leads.length
|
||||||
|
return {
|
||||||
|
overview: {
|
||||||
|
accounts: accounts.length,
|
||||||
|
activeAccounts: accounts.filter((account) => account.status === 'active').length,
|
||||||
|
leads: totalLeads,
|
||||||
|
conversion: totalLeads ? Math.round((purchased / totalLeads) * 100) : 0,
|
||||||
|
pointsCost: transactions
|
||||||
|
.filter((tx) => tx.status !== 'pending')
|
||||||
|
.reduce((sum, tx) => sum + Math.max(0, tx.amount), 0),
|
||||||
|
revenue: 0,
|
||||||
|
},
|
||||||
|
accounts,
|
||||||
|
roleMatrix: await this.repository.getRoleMatrix(),
|
||||||
|
channelStats: this.buildChannelStats(leads),
|
||||||
|
referralRules: await this.repository.getReferralRules(),
|
||||||
|
trend: this.buildDailyTrend(accounts, leads, filters),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDailyTrend(accounts = [], leads = [], filters = {}) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
let endDate = parseDateKey(filters.endDate) || today
|
||||||
|
if (endDate > today) endDate = today
|
||||||
|
let startDate = parseDateKey(filters.startDate) || shiftDate(endDate, -29)
|
||||||
|
if (startDate > endDate) startDate = shiftDate(endDate, -29)
|
||||||
|
if ((endDate - startDate) / DAY_MS > 365) startDate = shiftDate(endDate, -365)
|
||||||
|
|
||||||
|
const dates = []
|
||||||
|
const usersByDate = new Map()
|
||||||
|
const leadsByDate = new Map()
|
||||||
|
for (let cursor = new Date(startDate); cursor <= endDate; cursor = shiftDate(cursor, 1)) {
|
||||||
|
const key = formatDateKey(cursor)
|
||||||
|
dates.push(key)
|
||||||
|
usersByDate.set(key, 0)
|
||||||
|
leadsByDate.set(key, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts
|
||||||
|
.filter((account) => account.role === 'parent')
|
||||||
|
.forEach((account) => this.incrementDailyCount(usersByDate, account.createdAt))
|
||||||
|
leads.forEach((lead) => this.incrementDailyCount(leadsByDate, lead.createdAt))
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: formatDateKey(startDate),
|
||||||
|
endDate: formatDateKey(endDate),
|
||||||
|
dates,
|
||||||
|
users: dates.map((date) => usersByDate.get(date) || 0),
|
||||||
|
leads: dates.map((date) => leadsByDate.get(date) || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementDailyCount(counter, createdAt) {
|
||||||
|
const date = String(createdAt || '').slice(0, 10)
|
||||||
|
if (counter.has(date)) counter.set(date, counter.get(date) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildChannelStats(leads = []) {
|
||||||
|
const rows = new Map()
|
||||||
|
leads.forEach((lead) => {
|
||||||
|
const channel = String(lead.channel || '未标记渠道').trim() || '未标记渠道'
|
||||||
|
const current = rows.get(channel) || { channel, leads: 0, attended: 0, purchased: 0 }
|
||||||
|
current.leads += 1
|
||||||
|
if (['已到课', '已报名'].includes(lead.stage)) current.attended += 1
|
||||||
|
if (lead.stage === '已报名') current.purchased += 1
|
||||||
|
rows.set(channel, current)
|
||||||
|
})
|
||||||
|
return [...rows.values()].sort((left, right) => right.leads - left.leads || left.channel.localeCompare(right.channel, 'zh-CN'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccounts(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const role = String(filters.role || '').trim()
|
||||||
|
const status = String(filters.status || '').trim()
|
||||||
|
const wechatBound = String(filters.wechatBound || '').trim()
|
||||||
|
const accounts = await this.repository.listAccounts()
|
||||||
|
const filtered = accounts.filter((account) => {
|
||||||
|
const matchKeyword = !keyword || [
|
||||||
|
account.name,
|
||||||
|
account.phone,
|
||||||
|
account.username,
|
||||||
|
account.id,
|
||||||
|
account.roleName,
|
||||||
|
account.role,
|
||||||
|
account.wechatNickname,
|
||||||
|
account.wechatOpenid,
|
||||||
|
account.wechatUnionid,
|
||||||
|
].some((value) => String(value || '').toLowerCase().includes(keyword.toLowerCase()))
|
||||||
|
const matchRole = !role || role === 'all' || account.role === role
|
||||||
|
const matchStatus = !status || status === 'all' || account.status === status
|
||||||
|
const isWechatBound = Boolean(account.wechatOpenid || account.wechatUnionid)
|
||||||
|
const matchWechat = !wechatBound
|
||||||
|
|| wechatBound === 'all'
|
||||||
|
|| (wechatBound === 'bound' && isWechatBound)
|
||||||
|
|| (wechatBound === 'unbound' && !isWechatBound)
|
||||||
|
return matchKeyword && matchRole && matchStatus && matchWechat
|
||||||
|
})
|
||||||
|
if (String(filters.paged || '') !== '1') return this.decorateInvitationRelations(filtered)
|
||||||
|
const page = paginate(filtered, filters)
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
list: await this.decorateInvitationRelations(page.list),
|
||||||
|
overview: {
|
||||||
|
total: filtered.length,
|
||||||
|
active: filtered.filter((item) => item.status === 'active').length,
|
||||||
|
parents: filtered.filter((item) => item.role === 'parent').length,
|
||||||
|
admins: filtered.filter((item) => item.role !== 'parent').length,
|
||||||
|
wechatBound: filtered.filter((item) => item.wechatOpenid || item.wechatUnionid).length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async decorateInvitationRelations(accounts = []) {
|
||||||
|
const appointments = await this.repository.listAppointments()
|
||||||
|
const appointmentUsage = appointments.reduce((result, item) => {
|
||||||
|
if (item.status !== 'cancelled') result.set(item.userId, (result.get(item.userId) || 0) + 1)
|
||||||
|
return result
|
||||||
|
}, new Map())
|
||||||
|
|
||||||
|
return Promise.all(accounts.map(async (account) => {
|
||||||
|
const trialBookingLimit = Number.isInteger(Number(account.trialBookingLimit)) ? Number(account.trialBookingLimit) : 1
|
||||||
|
const trialBookingUsed = appointmentUsage.get(account.id) || 0
|
||||||
|
const bookingQuota = {
|
||||||
|
trialBookingLimit,
|
||||||
|
trialBookingUsed,
|
||||||
|
trialBookingRemaining: Math.max(0, trialBookingLimit - trialBookingUsed),
|
||||||
|
}
|
||||||
|
if (account.role !== 'parent') {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
...bookingQuota,
|
||||||
|
invitedBy: null,
|
||||||
|
invitedUsers: [],
|
||||||
|
invitedCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const referral = await this.repository.findReferralByInvitee?.(account.id)
|
||||||
|
const inviter = referral?.inviterId ? await this.repository.findUserById(referral.inviterId) : null
|
||||||
|
const invitedRecords = await this.repository.getReferralsByInviter(account.id)
|
||||||
|
const invitedUsers = await Promise.all(invitedRecords.map(async (item) => {
|
||||||
|
const invitee = item.inviteeId ? await this.repository.findUserById(item.inviteeId) : null
|
||||||
|
return {
|
||||||
|
id: invitee?.id || item.inviteeId || '',
|
||||||
|
name: invitee?.name || item.name,
|
||||||
|
phone: invitee?.phone || item.phone,
|
||||||
|
status: item.status,
|
||||||
|
childGrade: item.childGrade,
|
||||||
|
channel: item.channel,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
...bookingQuota,
|
||||||
|
invitedBy: inviter
|
||||||
|
? {
|
||||||
|
id: inviter.id,
|
||||||
|
name: inviter.name,
|
||||||
|
phone: inviter.phone,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
invitedUsers,
|
||||||
|
invitedCount: invitedUsers.length,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoles() {
|
||||||
|
return {
|
||||||
|
roles: await this.repository.getRoleMatrix(),
|
||||||
|
permissionTree: PERMISSION_TREE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRolePermissions(role, payload = {}) {
|
||||||
|
if (role === 'parent') {
|
||||||
|
const error = new Error('家长角色不参与后台权限配置')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const roleInfo = await this.repository.findRole(role)
|
||||||
|
if (!roleInfo) {
|
||||||
|
const error = new Error('角色不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const permissions = sanitizePermissions(payload.permissions || [])
|
||||||
|
if (role === 'super_admin' && !permissions.includes('*:*:*')) {
|
||||||
|
permissions.unshift('*:*:*')
|
||||||
|
}
|
||||||
|
return this.repository.updateRolePermissions(role, permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(payload = {}) {
|
||||||
|
const role = String(payload.role || 'parent').trim()
|
||||||
|
const phone = String(payload.phone || '').trim()
|
||||||
|
const username = String(payload.username || phone || '').trim()
|
||||||
|
const name = String(payload.name || '').trim()
|
||||||
|
const password = String(payload.password || '').trim()
|
||||||
|
const status = String(payload.status || 'active').trim()
|
||||||
|
const trialBookingLimit = parseTrialBookingLimit(payload.trialBookingLimit)
|
||||||
|
|
||||||
|
if (!VALID_ROLES.includes(role)) {
|
||||||
|
const error = new Error('角色无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!VALID_STATUSES.includes(status)) {
|
||||||
|
const error = new Error('账号状态无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!name || !phone || !/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('姓名和正确手机号必填')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!username) {
|
||||||
|
const error = new Error('登录账号不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const existed = await this.repository.findUserByLoginAccount(username)
|
||||||
|
const phoneExisted = await this.repository.findUserByPhone(phone)
|
||||||
|
if (existed || phoneExisted) {
|
||||||
|
const error = new Error('账号或手机号已存在')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleInfo = await this.repository.findRole(role)
|
||||||
|
const user = await this.repository.addUser({
|
||||||
|
id: createId(role === 'parent' ? 'parent' : 'user'),
|
||||||
|
username,
|
||||||
|
passwordHash: password ? hashPassword(password) : null,
|
||||||
|
role,
|
||||||
|
roleName: roleInfo?.name || role,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
status,
|
||||||
|
trialBookingLimit,
|
||||||
|
inviterCode: role === 'parent' ? `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}` : null,
|
||||||
|
phoneBound: true,
|
||||||
|
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
child: payload.childName
|
||||||
|
? {
|
||||||
|
id: createId('child'),
|
||||||
|
name: String(payload.childName).trim(),
|
||||||
|
grade: String(payload.childGrade || '待确认').trim(),
|
||||||
|
focus: String(payload.childFocus || '待测评').trim(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (role === 'parent') await this.repository.getPointAccount(user.id)
|
||||||
|
return this.repository.withPermissions(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(id, payload = {}, operator = null) {
|
||||||
|
const account = await this.repository.findUserById(id)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = payload.role !== undefined ? String(payload.role || '').trim() : account.role
|
||||||
|
const status = payload.status !== undefined ? String(payload.status || '').trim() : account.status
|
||||||
|
const phone = payload.phone !== undefined ? String(payload.phone || '').trim() : account.phone
|
||||||
|
const username = payload.username !== undefined ? String(payload.username || '').trim() : account.username
|
||||||
|
const name = payload.name !== undefined ? String(payload.name || '').trim() : account.name
|
||||||
|
const password = String(payload.password || '').trim()
|
||||||
|
const trialBookingLimit = parseTrialBookingLimit(payload.trialBookingLimit, account.trialBookingLimit ?? 1)
|
||||||
|
|
||||||
|
if (operator && role !== account.role && !hasPermission(operator, ['system:user:role'])) {
|
||||||
|
const error = new Error('没有分配角色权限')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (operator && status !== account.status && !hasPermission(operator, ['system:user:status'])) {
|
||||||
|
const error = new Error('没有启停账号权限')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ROLES.includes(role)) {
|
||||||
|
const error = new Error('角色无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!VALID_STATUSES.includes(status)) {
|
||||||
|
const error = new Error('账号状态无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!name || !phone || !/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('姓名和正确手机号必填')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!username) {
|
||||||
|
const error = new Error('登录账号不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.repository.listAccounts()
|
||||||
|
const duplicated = accounts.find((item) => item.id !== id && (item.username === username || item.phone === phone))
|
||||||
|
if (duplicated) {
|
||||||
|
const error = new Error('账号或手机号已存在')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = snapshotAccount(account)
|
||||||
|
const roleInfo = await this.repository.findRole(role)
|
||||||
|
const childName = String(payload.childName !== undefined ? payload.childName : account.child?.name || '').trim()
|
||||||
|
const childGrade = String(payload.childGrade !== undefined ? payload.childGrade : account.child?.grade || '').trim()
|
||||||
|
const childFocus = String(payload.childFocus !== undefined ? payload.childFocus : account.child?.focus || '').trim()
|
||||||
|
const patch = {
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
roleName: roleInfo?.name || role,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
status,
|
||||||
|
trialBookingLimit,
|
||||||
|
phoneBound: true,
|
||||||
|
phoneBoundAt: account.phoneBoundAt || new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
inviterCode: role === 'parent' ? account.inviterCode || `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}` : account.inviterCode,
|
||||||
|
child: role === 'parent'
|
||||||
|
? {
|
||||||
|
id: account.child?.id || createId('child'),
|
||||||
|
name: childName || '未填写',
|
||||||
|
grade: childGrade || '待确认',
|
||||||
|
focus: childFocus || '待测评',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
if (password) patch.passwordHash = hashPassword(password)
|
||||||
|
|
||||||
|
const updated = await this.repository.updateUser(id, patch)
|
||||||
|
if (role === 'parent') await this.repository.getPointAccount(id)
|
||||||
|
await this.syncParentDisplayInfo(previous, updated)
|
||||||
|
return this.repository.withPermissions(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(id) {
|
||||||
|
const account = await this.repository.findUserById(id)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (account.role === 'super_admin') {
|
||||||
|
const superAdmins = (await this.repository.listAccounts()).filter((item) => item.role === 'super_admin' && item.status !== 'disabled')
|
||||||
|
if (superAdmins.length <= 1) {
|
||||||
|
const error = new Error('至少保留一个启用的超级管理员')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [transactions, appointments, redemptions, invitedRecords, invitedBy] = await Promise.all([
|
||||||
|
this.repository.getPointTransactions(id),
|
||||||
|
this.repository.listAppointments({ userId: id }),
|
||||||
|
this.repository.listRedemptions({ userId: id }),
|
||||||
|
this.repository.getReferralsByInviter(id),
|
||||||
|
this.repository.findReferralByInvitee?.(id),
|
||||||
|
])
|
||||||
|
if (transactions.length || appointments.length || redemptions.length || invitedRecords.length || invitedBy) {
|
||||||
|
const error = new Error('该用户已有推荐、积分、预约或兑换记录,请停用账号以保留业务历史')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await this.repository.deleteUser(id)
|
||||||
|
return this.repository.withPermissions(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountRole(id, role) {
|
||||||
|
const account = await this.repository.updateAccountRole(id, role)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号或角色不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountStatus(id, status) {
|
||||||
|
if (!VALID_STATUSES.includes(status)) {
|
||||||
|
const error = new Error('账号状态无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const account = await this.repository.updateAccountStatus(id, status)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(id, stage) {
|
||||||
|
if (!LEAD_STAGE_FLOW.includes(stage)) {
|
||||||
|
const error = new Error('线索阶段无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const lead = await this.repository.updateLeadStage(id, stage)
|
||||||
|
if (!lead) {
|
||||||
|
const error = new Error('线索不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await this.syncReferralWithLeadStage(lead, stage)
|
||||||
|
return lead
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmLeadPurchase(id) {
|
||||||
|
return this.updateLeadStage(id, '已报名')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDecoratedLeads() {
|
||||||
|
const leads = await this.repository.getLeads()
|
||||||
|
return Promise.all(leads.map((lead) => this.decorateLead(lead)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async decorateLead(lead) {
|
||||||
|
const referral = await this.findReferralByLead(lead)
|
||||||
|
if (!referral) return lead
|
||||||
|
const invitee = referral.inviteeId ? await this.repository.findUserById(referral.inviteeId) : null
|
||||||
|
const hasIdentityLink = Boolean(
|
||||||
|
(lead.referralId && lead.referralId === referral.id)
|
||||||
|
|| (lead.inviteeId && referral.inviteeId && lead.inviteeId === referral.inviteeId),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...lead,
|
||||||
|
referralId: referral.id || lead.referralId,
|
||||||
|
inviteeId: referral.inviteeId || lead.inviteeId,
|
||||||
|
parent: invitee?.name || referral.name || lead.parent,
|
||||||
|
phone: invitee?.phone || referral.phone || '',
|
||||||
|
childGrade: invitee?.child?.grade || referral.childGrade || '',
|
||||||
|
stage: hasIdentityLink ? this.maxLeadStage(lead.stage, referral.status) : lead.stage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLeadStage(leadStage, referralStage) {
|
||||||
|
const leadRank = LEAD_STAGE_FLOW.indexOf(leadStage)
|
||||||
|
const referralRank = LEAD_STAGE_FLOW.indexOf(referralStage)
|
||||||
|
if (referralRank < 0) return leadStage
|
||||||
|
if (leadRank < 0) return referralStage
|
||||||
|
return LEAD_STAGE_FLOW[Math.max(leadRank, referralRank)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncParentDisplayInfo(previous, current) {
|
||||||
|
if (!current || current.role !== 'parent') return
|
||||||
|
const referrals = await this.repository.updateReferralInviteeInfo?.(current.id, {
|
||||||
|
name: current.name,
|
||||||
|
phone: current.phone,
|
||||||
|
childGrade: current.child?.grade || '待确认',
|
||||||
|
})
|
||||||
|
if (Array.isArray(referrals) && referrals.length && this.repository.updateLeadInfoByReferral) {
|
||||||
|
await Promise.all(referrals.map((referral) => this.repository.updateLeadInfoByReferral(referral, {
|
||||||
|
parent: current.name,
|
||||||
|
}, previous || {})))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (previous?.name && previous.name !== current.name) {
|
||||||
|
await this.repository.updateLeadsByParent?.(previous.name, { parent: current.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncReferralWithLeadStage(lead, targetStage) {
|
||||||
|
if (!this.pointService || !REFERRAL_STAGE_EVENTS[targetStage]) return null
|
||||||
|
const referral = await this.findReferralByLead(lead)
|
||||||
|
if (!referral) return null
|
||||||
|
|
||||||
|
const currentRank = Math.max(0, REFERRAL_STAGE_FLOW.indexOf(referral.status))
|
||||||
|
const targetRank = REFERRAL_STAGE_FLOW.indexOf(targetStage)
|
||||||
|
if (targetRank <= currentRank) return referral
|
||||||
|
|
||||||
|
let nextReferral = referral
|
||||||
|
for (let rank = currentRank + 1; rank <= targetRank; rank += 1) {
|
||||||
|
const nextStatus = REFERRAL_STAGE_FLOW[rank]
|
||||||
|
const event = REFERRAL_STAGE_EVENTS[nextStatus]
|
||||||
|
if (!event) continue
|
||||||
|
nextReferral = await this.applyReferralStageReward(nextReferral, nextStatus, event)
|
||||||
|
}
|
||||||
|
return nextReferral
|
||||||
|
}
|
||||||
|
|
||||||
|
async findReferralByLead(lead) {
|
||||||
|
const matchedReferral = await this.repository.findReferralForLead?.(lead)
|
||||||
|
if (matchedReferral) return matchedReferral
|
||||||
|
|
||||||
|
if (lead?.referralId && this.repository.findReferralById) {
|
||||||
|
const referral = await this.repository.findReferralById(lead.referralId)
|
||||||
|
if (referral) return referral
|
||||||
|
}
|
||||||
|
if (lead?.inviteeId && this.repository.findReferralByInvitee) {
|
||||||
|
const referral = await this.repository.findReferralByInvitee(lead.inviteeId)
|
||||||
|
if (referral) return referral
|
||||||
|
}
|
||||||
|
if (!lead?.parent || !lead?.source) return null
|
||||||
|
const accounts = await this.repository.listAccounts()
|
||||||
|
const inviter = accounts.find((account) => account.role === 'parent' && [account.id, account.name, account.phone].filter(Boolean).includes(lead.source))
|
||||||
|
if (!inviter) return null
|
||||||
|
|
||||||
|
const records = await this.repository.getReferralsByInviter(inviter.id)
|
||||||
|
const matches = []
|
||||||
|
for (const record of records) {
|
||||||
|
const invitee = record.inviteeId ? await this.repository.findUserById(record.inviteeId) : null
|
||||||
|
const fallbackNames = [
|
||||||
|
invitee?.name,
|
||||||
|
record.name,
|
||||||
|
invitee?.phone ? `家长${String(invitee.phone).slice(-4)}` : '',
|
||||||
|
record.phone ? `家长${String(record.phone).slice(-4)}` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
if (fallbackNames.includes(lead.parent)) matches.push(record)
|
||||||
|
}
|
||||||
|
if (!matches.length) return null
|
||||||
|
return matches.find((record) => !lead.channel || record.channel === lead.channel) || matches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyReferralStageReward(referral, nextStatus, event) {
|
||||||
|
const rule = await this.pointService.getReferralRule(event) || { inviter: 0, invitee: 0 }
|
||||||
|
const inviterPoints = Number(rule.inviter || 0)
|
||||||
|
const inviteePoints = Number(rule.invitee || 0)
|
||||||
|
const inviteeId = referral.inviteeId
|
||||||
|
|
||||||
|
if (event === 'attended') {
|
||||||
|
await this.settleReferralPending(referral.inviterId, `${referral.name}确认到课`)
|
||||||
|
if (inviteeId) await this.settleReferralPending(inviteeId, '推荐确认到课')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviterPoints > 0) {
|
||||||
|
await this.pointService.grant(referral.inviterId, `${referral.name}${rule.title || nextStatus}`, inviterPoints, event === 'booked' ? 'pending' : 'available')
|
||||||
|
}
|
||||||
|
if (inviteeId && inviteePoints > 0) {
|
||||||
|
await this.pointService.grant(inviteeId, `推荐${rule.title || nextStatus}奖励`, inviteePoints, event === 'booked' ? 'pending' : 'available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.updateReferral(referral.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
reward: Number(referral.reward || 0) + inviterPoints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async settleReferralPending(userId, title) {
|
||||||
|
const account = await this.repository.getPointAccount(userId)
|
||||||
|
if (Number(account.pending || 0) > 0) {
|
||||||
|
await this.pointService.settlePending(userId, account.pending, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AdminService }
|
||||||
@ -0,0 +1,482 @@
|
|||||||
|
const { hashPassword, verifyPassword } = require('../utils/password')
|
||||||
|
const { createId } = require('../utils/ids')
|
||||||
|
const { env } = require('../config/env')
|
||||||
|
const { createSessionToken } = require('../utils/sessionToken')
|
||||||
|
|
||||||
|
function snapshotAccount(account) {
|
||||||
|
if (!account) return null
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
child: account.child ? { ...account.child } : account.child,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
constructor(repository, pointService, smsService) {
|
||||||
|
this.repository = repository
|
||||||
|
this.pointService = pointService
|
||||||
|
this.smsService = smsService
|
||||||
|
}
|
||||||
|
|
||||||
|
withSession(account) {
|
||||||
|
if (!account) return account
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
sessionToken: createSessionToken(account.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(credentials = {}) {
|
||||||
|
const loginAccount = String(credentials.account || credentials.username || credentials.phone || credentials.accountId || '').trim()
|
||||||
|
const password = String(credentials.password || '')
|
||||||
|
if (!loginAccount || !password) {
|
||||||
|
const error = new Error('账号和密码不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const rawAccount = await this.repository.findUserByLoginAccount(loginAccount)
|
||||||
|
if (!rawAccount) {
|
||||||
|
const error = new Error('账号或密码错误')
|
||||||
|
error.status = 401
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!verifyPassword(password, rawAccount.passwordHash)) {
|
||||||
|
const error = new Error('账号或密码错误')
|
||||||
|
error.status = 401
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const account = await this.repository.touchLogin(rawAccount.id)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (account.status === 'disabled') {
|
||||||
|
const error = new Error('账号已停用')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.withSession(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginByPhone(payload = {}) {
|
||||||
|
const phone = String(payload.phone || '').trim()
|
||||||
|
const code = String(payload.code || '').trim()
|
||||||
|
const inviterCode = String(payload.inviterCode || payload.inviteCode || '').trim().toUpperCase()
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('手机号格式不正确')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.verifySmsCode(phone, code, 'login')
|
||||||
|
|
||||||
|
let account = await this.repository.findUserByPhone(phone)
|
||||||
|
const isNewAccount = !account
|
||||||
|
if (isNewAccount && inviterCode) await this.assertInviteCode(inviterCode)
|
||||||
|
if (!account) {
|
||||||
|
account = await this.repository.addUser({
|
||||||
|
id: createId('parent'),
|
||||||
|
username: phone,
|
||||||
|
role: 'parent',
|
||||||
|
roleName: '家长',
|
||||||
|
name: `家长${phone.slice(-4)}`,
|
||||||
|
phone,
|
||||||
|
status: 'active',
|
||||||
|
inviterCode: `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
|
||||||
|
phoneBound: true,
|
||||||
|
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
})
|
||||||
|
await this.repository.getPointAccount(account.id)
|
||||||
|
if (inviterCode) await this.applyReferralCode(account, inviterCode, '小程序邀请码')
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeAccount = await this.repository.touchLogin(account.id)
|
||||||
|
if (!safeAccount) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (safeAccount.status === 'disabled') {
|
||||||
|
const error = new Error('账号已停用')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!safeAccount.phoneBound) {
|
||||||
|
const error = new Error('请先绑定手机号')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.withSession(safeAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginByWechat(payload = {}) {
|
||||||
|
const code = String(payload.code || '').trim()
|
||||||
|
if (!code) {
|
||||||
|
const error = new Error('缺少微信登录 code')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const wechat = await this.exchangeWechatCode(code)
|
||||||
|
const account = await this.findWechatAccount(wechat)
|
||||||
|
if (!account) {
|
||||||
|
return { needPhoneBinding: true }
|
||||||
|
}
|
||||||
|
if (account.status === 'disabled') {
|
||||||
|
const error = new Error('账号已停用')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!account.phoneBound || !account.phone) {
|
||||||
|
return { needPhoneBinding: true, account: await this.repository.withPermissions(account) }
|
||||||
|
}
|
||||||
|
return { needPhoneBinding: false, account: this.withSession(await this.repository.touchLogin(account.id)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindWechatPhone(payload = {}) {
|
||||||
|
const phone = String(payload.phone || '').trim()
|
||||||
|
const code = String(payload.code || '').trim()
|
||||||
|
const name = String(payload.name || '').trim()
|
||||||
|
const inviterCode = String(payload.inviterCode || payload.inviteCode || '').trim().toUpperCase()
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('手机号格式不正确')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.verifySmsCode(phone, code, 'bind_wechat')
|
||||||
|
|
||||||
|
const wechatCode = String(payload.wechatCode || payload.wxCode || '').trim()
|
||||||
|
if (!wechatCode) {
|
||||||
|
const error = new Error('缺少微信登录 code')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const wechat = await this.exchangeWechatCode(wechatCode)
|
||||||
|
const wechatAccount = await this.findWechatAccount(wechat)
|
||||||
|
const phoneAccount = await this.repository.findUserByPhone(phone)
|
||||||
|
if (wechatAccount?.phone && wechatAccount.phone !== phone) {
|
||||||
|
const error = new Error('该微信已绑定其他手机号,请在当前账号的账号安全中换绑手机号')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (phoneAccount && this.isAccountBoundToOtherWechat(phoneAccount, wechat)) {
|
||||||
|
const error = new Error('该手机号已绑定其他微信,请使用原微信登录或联系管理员处理')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (wechatAccount && phoneAccount && wechatAccount.id !== phoneAccount.id) {
|
||||||
|
const error = new Error('该微信和手机号已分别绑定不同账号,请联系管理员处理')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = phoneAccount || wechatAccount
|
||||||
|
const isNewAccount = !account
|
||||||
|
if (isNewAccount && inviterCode) await this.assertInviteCode(inviterCode)
|
||||||
|
if (account) {
|
||||||
|
const previous = snapshotAccount(account)
|
||||||
|
account = await this.repository.updateUser(account.id, {
|
||||||
|
name: name || account.name,
|
||||||
|
phone,
|
||||||
|
wechatOpenid: wechat.openid,
|
||||||
|
wechatUnionid: wechat.unionid || account.wechatUnionid,
|
||||||
|
loginProvider: 'wechat_phone',
|
||||||
|
phoneBound: true,
|
||||||
|
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
wechatBoundAt: account.wechatBoundAt || new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
})
|
||||||
|
await this.syncParentDisplayInfo(previous, account)
|
||||||
|
} else {
|
||||||
|
account = await this.repository.addUser({
|
||||||
|
id: createId('parent'),
|
||||||
|
username: phone,
|
||||||
|
role: 'parent',
|
||||||
|
roleName: '家长',
|
||||||
|
name: name || `家长${phone.slice(-4)}`,
|
||||||
|
phone,
|
||||||
|
wechatOpenid: wechat.openid,
|
||||||
|
wechatUnionid: wechat.unionid || null,
|
||||||
|
loginProvider: 'wechat_phone',
|
||||||
|
status: 'active',
|
||||||
|
inviterCode: `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
|
||||||
|
phoneBound: true,
|
||||||
|
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
wechatBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
})
|
||||||
|
await this.repository.getPointAccount(account.id)
|
||||||
|
if (inviterCode) await this.applyReferralCode(account, inviterCode, '微信绑定邀请码')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.withSession(await this.repository.touchLogin(account.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(accountId, payload = {}) {
|
||||||
|
if (!accountId) {
|
||||||
|
const error = new Error('缺少账号 ID')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const account = await this.repository.findUserById(accountId)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = {}
|
||||||
|
if (payload.name !== undefined) patch.name = String(payload.name || '').trim()
|
||||||
|
if (payload.wechatNickname !== undefined) patch.wechatNickname = String(payload.wechatNickname || '').trim()
|
||||||
|
if (payload.wechatAvatar !== undefined) patch.wechatAvatar = String(payload.wechatAvatar || '').trim()
|
||||||
|
const childName = String(payload.childName || account.child?.name || '').trim()
|
||||||
|
const childGrade = String(payload.childGrade || account.child?.grade || '').trim()
|
||||||
|
const childFocus = String(payload.childFocus || account.child?.focus || '').trim()
|
||||||
|
if (childName || childGrade || childFocus) {
|
||||||
|
patch.child = {
|
||||||
|
id: account.child?.id || createId('child'),
|
||||||
|
name: childName || '未填写',
|
||||||
|
grade: childGrade || '待确认',
|
||||||
|
focus: childFocus || '待测评',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.name !== undefined && !patch.name) {
|
||||||
|
const error = new Error('姓名不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = snapshotAccount(account)
|
||||||
|
const updated = await this.repository.updateUser(accountId, patch)
|
||||||
|
await this.syncParentDisplayInfo(previous, updated)
|
||||||
|
return this.repository.withPermissions(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePassword(accountId, payload = {}) {
|
||||||
|
const account = await this.repository.findUserById(accountId)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPassword = String(payload.oldPassword || '').trim()
|
||||||
|
const password = String(payload.password || payload.newPassword || '').trim()
|
||||||
|
const confirmPassword = String(payload.confirmPassword || '').trim()
|
||||||
|
if (password.length < 6) {
|
||||||
|
const error = new Error('密码至少需要 6 位')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (confirmPassword && confirmPassword !== password) {
|
||||||
|
const error = new Error('两次输入的密码不一致')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (account.passwordHash && !verifyPassword(oldPassword, account.passwordHash)) {
|
||||||
|
const error = new Error('原密码不正确')
|
||||||
|
error.status = 401
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.repository.updateUser(accountId, {
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
loginProvider: account.wechatOpenid ? 'wechat_phone_password' : 'password',
|
||||||
|
})
|
||||||
|
return this.repository.withPermissions(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePhone(accountId, payload = {}) {
|
||||||
|
const account = await this.repository.findUserById(accountId)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = String(payload.phone || '').trim()
|
||||||
|
const code = String(payload.code || '').trim()
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('手机号格式不正确')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.verifySmsCode(phone, code, 'update_phone')
|
||||||
|
|
||||||
|
const existed = await this.repository.findUserByPhone(phone)
|
||||||
|
if (existed && existed.id !== accountId) {
|
||||||
|
const error = new Error('该手机号已被其他账号绑定')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
phone,
|
||||||
|
phoneBound: true,
|
||||||
|
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||||
|
}
|
||||||
|
if (!account.username || account.username === account.phone) {
|
||||||
|
patch.username = phone
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = snapshotAccount(account)
|
||||||
|
const updated = await this.repository.updateUser(accountId, patch)
|
||||||
|
await this.syncParentDisplayInfo(previous, updated)
|
||||||
|
return this.repository.withPermissions(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async current(accountId) {
|
||||||
|
if (!accountId) {
|
||||||
|
const error = new Error('缺少账号 ID')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const account = await this.repository.findUserById(accountId)
|
||||||
|
if (!account) {
|
||||||
|
const error = new Error('账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const safeAccount = await this.repository.withPermissions(account)
|
||||||
|
const appointments = await this.repository.listAppointments({ userId: account.id })
|
||||||
|
const trialBookingUsed = appointments.filter((item) => item.status !== 'cancelled').length
|
||||||
|
const trialBookingLimit = Number.isInteger(Number(safeAccount.trialBookingLimit)) ? Number(safeAccount.trialBookingLimit) : 1
|
||||||
|
return {
|
||||||
|
...safeAccount,
|
||||||
|
trialBookingLimit,
|
||||||
|
trialBookingUsed,
|
||||||
|
trialBookingRemaining: Math.max(0, trialBookingLimit - trialBookingUsed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWechatAccount(wechat) {
|
||||||
|
const byOpenid = wechat.openid ? await this.repository.findUserByLoginAccount(wechat.openid) : null
|
||||||
|
if (byOpenid) return byOpenid
|
||||||
|
return wechat.unionid ? await this.repository.findUserByLoginAccount(wechat.unionid) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
isAccountBoundToOtherWechat(account, wechat) {
|
||||||
|
if (!account) return false
|
||||||
|
const openid = String(wechat?.openid || '').trim()
|
||||||
|
const unionid = String(wechat?.unionid || '').trim()
|
||||||
|
const accountOpenid = String(account.wechatOpenid || '').trim()
|
||||||
|
const accountUnionid = String(account.wechatUnionid || '').trim()
|
||||||
|
if (accountOpenid && accountOpenid !== openid) return true
|
||||||
|
if (accountUnionid && unionid && accountUnionid !== unionid) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertInviteCode(inviterCode) {
|
||||||
|
const inviter = await this.repository.findUserByInviteCode(inviterCode)
|
||||||
|
if (!inviter) {
|
||||||
|
const error = new Error('邀请码无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return inviter
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSmsCode(payload = {}) {
|
||||||
|
if (!this.smsService) {
|
||||||
|
const error = new Error('短信服务未启用')
|
||||||
|
error.status = 501
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.smsService.sendVerificationCode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifySmsCode(phone, code, purpose) {
|
||||||
|
if (!this.smsService) {
|
||||||
|
const error = new Error('短信服务未启用')
|
||||||
|
error.status = 501
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.smsService.verifyCode(phone, code, purpose)
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncParentDisplayInfo(previous, current) {
|
||||||
|
if (!current || current.role !== 'parent') return
|
||||||
|
const referrals = await this.repository.updateReferralInviteeInfo?.(current.id, {
|
||||||
|
name: current.name,
|
||||||
|
phone: current.phone,
|
||||||
|
childGrade: current.child?.grade || '待确认',
|
||||||
|
})
|
||||||
|
if (Array.isArray(referrals) && referrals.length && this.repository.updateLeadInfoByReferral) {
|
||||||
|
await Promise.all(referrals.map((referral) => this.repository.updateLeadInfoByReferral(referral, {
|
||||||
|
parent: current.name,
|
||||||
|
}, previous || {})))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (previous?.name && previous.name !== current.name) {
|
||||||
|
await this.repository.updateLeadsByParent?.(previous.name, { parent: current.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyReferralCode(invitee, inviterCode, channel) {
|
||||||
|
if (!inviterCode || !invitee || invitee.role !== 'parent') return null
|
||||||
|
const inviter = await this.assertInviteCode(inviterCode)
|
||||||
|
if (inviter.id === invitee.id) return null
|
||||||
|
const records = await this.repository.getReferralsByInviter(inviter.id)
|
||||||
|
const existed = records.some((item) => item.inviteeId === invitee.id || item.phone === invitee.phone)
|
||||||
|
if (existed) return null
|
||||||
|
|
||||||
|
const rule = await this.pointService.getReferralRule('registered') || { inviter: 20, invitee: 20 }
|
||||||
|
const referral = await this.repository.addReferral({
|
||||||
|
id: createId('rel'),
|
||||||
|
inviterId: inviter.id,
|
||||||
|
inviteeId: invitee.id,
|
||||||
|
name: invitee.name,
|
||||||
|
phone: invitee.phone,
|
||||||
|
childGrade: invitee.child?.grade || '待确认',
|
||||||
|
status: '已注册',
|
||||||
|
reward: rule.inviter,
|
||||||
|
channel,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.repository.addLead({
|
||||||
|
id: createId('lead'),
|
||||||
|
referralId: referral.id,
|
||||||
|
inviteeId: invitee.id,
|
||||||
|
parent: invitee.name,
|
||||||
|
source: inviter.name,
|
||||||
|
stage: '已注册',
|
||||||
|
advisor: '未分配',
|
||||||
|
risk: '正常',
|
||||||
|
channel,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rule.inviter > 0) await this.pointService.grant(inviter.id, `${invitee.name}完成邀请码注册`, rule.inviter)
|
||||||
|
if (rule.invitee > 0) await this.pointService.grant(invitee.id, '填写邀请码注册奖励', rule.invitee)
|
||||||
|
return referral
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeWechatCode(code) {
|
||||||
|
if (!env.wechat.appId || !env.wechat.appSecret) {
|
||||||
|
const error = new Error('微信登录尚未配置 AppID 和 AppSecret')
|
||||||
|
error.status = 501
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
appid: env.wechat.appId,
|
||||||
|
secret: env.wechat.appSecret,
|
||||||
|
js_code: code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
})
|
||||||
|
const response = await fetch(`https://api.weixin.qq.com/sns/jscode2session?${params.toString()}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok || data.errcode) {
|
||||||
|
const error = new Error(data.errmsg || '微信登录校验失败')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!data.openid) {
|
||||||
|
const error = new Error('微信登录未返回 openid')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
openid: data.openid,
|
||||||
|
unionid: data.unionid || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AuthService }
|
||||||
@ -0,0 +1,369 @@
|
|||||||
|
const { createId } = require('../utils/ids')
|
||||||
|
|
||||||
|
function readPagination(filters = {}) {
|
||||||
|
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
|
||||||
|
return { pageNum, pageSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate(list = [], filters = {}) {
|
||||||
|
const { pageNum, pageSize } = readPagination(filters)
|
||||||
|
const start = (pageNum - 1) * pageSize
|
||||||
|
return {
|
||||||
|
list: list.slice(start, start + pageSize),
|
||||||
|
total: list.length,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDateTime(value) {
|
||||||
|
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/)
|
||||||
|
if (!match) return false
|
||||||
|
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]))
|
||||||
|
return date.getFullYear() === Number(match[1])
|
||||||
|
&& date.getMonth() === Number(match[2]) - 1
|
||||||
|
&& date.getDate() === Number(match[3])
|
||||||
|
&& date.getHours() === Number(match[4])
|
||||||
|
&& date.getMinutes() === Number(match[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
class CourseService {
|
||||||
|
constructor(repository, pointService) {
|
||||||
|
this.repository = repository
|
||||||
|
this.pointService = pointService
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeCoursePayload(payload = {}) {
|
||||||
|
const patch = {}
|
||||||
|
if (payload.name !== undefined) patch.name = String(payload.name).trim()
|
||||||
|
if (payload.nextTime !== undefined) {
|
||||||
|
patch.nextTime = String(payload.nextTime).trim()
|
||||||
|
if (patch.nextTime && !isValidDateTime(patch.nextTime)) {
|
||||||
|
const error = new Error('试听时间格式无效,请重新选择日期和时间')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.campus !== undefined) patch.campus = String(payload.campus).trim()
|
||||||
|
if (payload.teacher !== undefined) patch.teacher = String(payload.teacher).trim()
|
||||||
|
if (payload.status !== undefined) patch.status = ['active', 'paused'].includes(payload.status) ? payload.status : 'active'
|
||||||
|
if (payload.seats !== undefined) patch.seats = Number(payload.seats)
|
||||||
|
if (payload.pointsBack !== undefined) patch.pointsBack = Number(payload.pointsBack)
|
||||||
|
if (payload.trialMinutes !== undefined) patch.trialMinutes = Number(payload.trialMinutes)
|
||||||
|
if (payload.tags !== undefined) patch.tags = Array.isArray(payload.tags) ? payload.tags : String(payload.tags).split(',').map((item) => item.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
for (const key of ['seats', 'pointsBack', 'trialMinutes']) {
|
||||||
|
if (patch[key] !== undefined && (!Number.isFinite(patch[key]) || patch[key] < 0)) {
|
||||||
|
const error = new Error('课程数值字段无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCourses(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const campus = String(filters.campus || '').trim()
|
||||||
|
const courses = await this.repository.getCourses()
|
||||||
|
const active = courses.filter((course) => course.status !== 'paused')
|
||||||
|
const filtered = active.filter((course) => {
|
||||||
|
const matchKeyword = !keyword || [course.name, course.teacher, course.campus, ...(course.tags || [])].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchCampus = !campus || campus === 'all' || campus === '全部' || course.campus === campus
|
||||||
|
return matchKeyword && matchCampus
|
||||||
|
})
|
||||||
|
if (String(filters.paged || '') !== '1') return filtered
|
||||||
|
return {
|
||||||
|
...paginate(filtered, filters),
|
||||||
|
campusOptions: [...new Set(active.map((item) => item.campus).filter(Boolean))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminCourses(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const status = String(filters.status || '').trim()
|
||||||
|
const campus = String(filters.campus || '').trim()
|
||||||
|
const courses = await this.repository.getCourses()
|
||||||
|
const filtered = courses.filter((course) => {
|
||||||
|
const matchKeyword = !keyword || [course.name, course.teacher, course.campus, ...(course.tags || [])].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchStatus = !status || status === '全部' || course.status === status
|
||||||
|
const matchCampus = !campus || campus === '全部' || course.campus === campus
|
||||||
|
return matchKeyword && matchStatus && matchCampus
|
||||||
|
})
|
||||||
|
if (String(filters.paged || '') !== '1') return filtered
|
||||||
|
const active = filtered.filter((item) => item.status === 'active')
|
||||||
|
const overview = {
|
||||||
|
total: filtered.length,
|
||||||
|
active: active.length,
|
||||||
|
seats: active.reduce((sum, item) => sum + Number(item.seats || 0), 0),
|
||||||
|
campuses: new Set(filtered.map((item) => item.campus).filter(Boolean)).size,
|
||||||
|
avgPoints: active.length ? Math.round(active.reduce((sum, item) => sum + Number(item.pointsBack || 0), 0) / active.length) : 0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...paginate(filtered, filters),
|
||||||
|
overview,
|
||||||
|
campusOptions: [...new Set(courses.map((item) => item.campus).filter(Boolean))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminCourse(id) {
|
||||||
|
const course = await this.repository.findCourse(id)
|
||||||
|
if (!course) {
|
||||||
|
const error = new Error('课程不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAppointments(filters = {}) {
|
||||||
|
const appointments = await this.repository.listAppointments(filters)
|
||||||
|
const overview = appointments.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
result.total += 1
|
||||||
|
result[item.status] = (result[item.status] || 0) + 1
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ total: 0, booked: 0, attended: 0, cancelled: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...paginate(appointments, filters),
|
||||||
|
overview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserAppointments(userId) {
|
||||||
|
const value = String(userId || '').trim()
|
||||||
|
if (!value) {
|
||||||
|
const error = new Error('缺少预约账号')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.repository.listAppointments({ userId: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async attendAppointment(id) {
|
||||||
|
const appointment = await this.repository.findAppointmentById(id)
|
||||||
|
if (!appointment) {
|
||||||
|
const error = new Error('预约记录不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (appointment.status === 'attended') return appointment
|
||||||
|
if (appointment.status !== 'booked') {
|
||||||
|
const error = new Error('当前预约状态不能确认到课')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const referral = await this.repository.findReferralByInvitee?.(appointment.userId)
|
||||||
|
const bookingRule = referral
|
||||||
|
? await this.pointService.getReferralRule('booked') || { inviter: 50, invitee: 50 }
|
||||||
|
: { inviter: 0, invitee: 0 }
|
||||||
|
const inviteeSettlement = await this.pointService.settlePendingReference(
|
||||||
|
appointment.userId,
|
||||||
|
'appointment',
|
||||||
|
appointment.id,
|
||||||
|
`确认${appointment.course?.name || '试听课'}到课`,
|
||||||
|
)
|
||||||
|
if (!inviteeSettlement) {
|
||||||
|
await this.settleLegacyAppointmentPoints(
|
||||||
|
appointment.userId,
|
||||||
|
Number(appointment.course?.pointsBack || 0) + Number(bookingRule.invitee || 0),
|
||||||
|
`确认${appointment.course?.name || '试听课'}到课`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referral && !['已到课', '已报名'].includes(referral.status)) {
|
||||||
|
const inviterSettlement = await this.pointService.settlePendingReference(
|
||||||
|
referral.inviterId,
|
||||||
|
'appointment',
|
||||||
|
appointment.id,
|
||||||
|
`${referral.name}确认到课`,
|
||||||
|
)
|
||||||
|
if (!inviterSettlement) {
|
||||||
|
await this.settleLegacyAppointmentPoints(
|
||||||
|
referral.inviterId,
|
||||||
|
Number(bookingRule.inviter || 0),
|
||||||
|
`${referral.name}确认到课`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rule = await this.pointService.getReferralRule('attended') || { inviter: 150, invitee: 100 }
|
||||||
|
const updatedReferral = await this.repository.updateReferral(referral.id, {
|
||||||
|
status: '已到课',
|
||||||
|
reward: Number(referral.reward || 0) + Number(rule.inviter || 0),
|
||||||
|
})
|
||||||
|
await this.repository.updateLeadStageByReferral?.(updatedReferral || referral, '已到课')
|
||||||
|
if (rule.inviter > 0) await this.pointService.grant(referral.inviterId, `${referral.name}确认到课`, rule.inviter)
|
||||||
|
if (rule.invitee > 0) await this.pointService.grant(appointment.userId, '推荐到课奖励', rule.invitee)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.updateAppointment(id, { status: 'attended' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async settleLegacyAppointmentPoints(userId, expectedAmount, title) {
|
||||||
|
if (expectedAmount <= 0) return null
|
||||||
|
const account = await this.repository.getPointAccount(userId)
|
||||||
|
const amount = Math.min(Number(account.pending || 0), expectedAmount)
|
||||||
|
if (amount <= 0) return null
|
||||||
|
return this.pointService.settlePending(userId, amount, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCourse(payload) {
|
||||||
|
const course = {
|
||||||
|
id: createId('course'),
|
||||||
|
name: '新试听课程',
|
||||||
|
tags: ['试听'],
|
||||||
|
nextTime: '',
|
||||||
|
pointsBack: 0,
|
||||||
|
seats: 0,
|
||||||
|
campus: '',
|
||||||
|
teacher: '',
|
||||||
|
trialMinutes: 45,
|
||||||
|
status: 'paused',
|
||||||
|
...this.normalizeCoursePayload(payload),
|
||||||
|
}
|
||||||
|
if (!course.name) {
|
||||||
|
const error = new Error('课程名称不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return this.repository.addCourse(course)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCourse(id, payload) {
|
||||||
|
const patch = this.normalizeCoursePayload(payload)
|
||||||
|
if (patch.name !== undefined && !patch.name) {
|
||||||
|
const error = new Error('课程名称不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const course = await this.repository.updateCourse(id, patch)
|
||||||
|
if (!course) {
|
||||||
|
const error = new Error('课程不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCourse(id) {
|
||||||
|
const appointments = await this.repository.listAppointments({ courseId: id })
|
||||||
|
if (appointments.length) {
|
||||||
|
const error = new Error('该课程已有预约记录,不能删除;请暂停课程以保留历史记录')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (patch.nextTime !== undefined && !patch.nextTime) {
|
||||||
|
const error = new Error('请选择试听日期和时间')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!course.nextTime) {
|
||||||
|
const error = new Error('请选择试听日期和时间')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const course = await this.repository.deleteCourse(id)
|
||||||
|
if (!course) {
|
||||||
|
const error = new Error('课程不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return course
|
||||||
|
}
|
||||||
|
|
||||||
|
async book(payload) {
|
||||||
|
const userId = String(payload.userId || '').trim()
|
||||||
|
if (!userId) {
|
||||||
|
const error = new Error('缺少预约账号')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const user = await this.repository.findUserById(userId)
|
||||||
|
if (!user || user.role !== 'parent') {
|
||||||
|
const error = new Error('家长账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
const error = new Error('账号已停用,不能预约课程')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!user.phoneBound || !user.phone) {
|
||||||
|
const error = new Error('请先绑定手机号再预约课程')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (payload.childId && user.child?.id && payload.childId !== user.child.id) {
|
||||||
|
const error = new Error('预约学生信息与当前账号不一致')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const appointments = await this.repository.listAppointments({ userId })
|
||||||
|
const activeAppointments = appointments.filter((item) => item.status !== 'cancelled')
|
||||||
|
if (activeAppointments.some((item) => item.courseId === payload.courseId)) {
|
||||||
|
const error = new Error('该试听课程已经预约,请勿重复预约')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const trialBookingLimit = Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1
|
||||||
|
if (activeAppointments.length >= trialBookingLimit) {
|
||||||
|
const error = new Error(trialBookingLimit > 0
|
||||||
|
? `当前账号最多可预约 ${trialBookingLimit} 次试听课程`
|
||||||
|
: '当前账号暂不可预约试听课程')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const course = await this.repository.findCourse(payload.courseId)
|
||||||
|
if (!course) {
|
||||||
|
const error = new Error('课程不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (course.status !== 'active') {
|
||||||
|
const error = new Error('该课程已暂停预约')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (course.seats <= 0) {
|
||||||
|
const error = new Error('该课程已约满')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointment = await this.repository.addAppointment({
|
||||||
|
id: createId('appt'),
|
||||||
|
userId,
|
||||||
|
childId: payload.childId,
|
||||||
|
courseId: payload.courseId,
|
||||||
|
status: 'booked',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.repository.updateCourse(course.id, { seats: course.seats - 1 })
|
||||||
|
const referral = await this.repository.findReferralByInvitee?.(userId)
|
||||||
|
if (referral && !['已预约', '已到课', '已报名'].includes(referral.status)) {
|
||||||
|
const rule = await this.pointService.getReferralRule('booked') || { inviter: 50, invitee: 50 }
|
||||||
|
const updatedReferral = await this.repository.updateReferral(referral.id, {
|
||||||
|
status: '已预约',
|
||||||
|
reward: Number(referral.reward || 0) + Number(rule.inviter || 0),
|
||||||
|
})
|
||||||
|
await this.repository.updateLeadStageByReferral?.(updatedReferral || referral, '已预约')
|
||||||
|
const reference = { referenceType: 'appointment', referenceId: appointment.id }
|
||||||
|
if (rule.inviter > 0) await this.pointService.grant(referral.inviterId, `${referral.name}预约试听`, rule.inviter, 'pending', reference)
|
||||||
|
if (rule.invitee > 0) await this.pointService.grant(userId, '推荐预约试听奖励', rule.invitee, 'pending', reference)
|
||||||
|
}
|
||||||
|
if (course.pointsBack > 0) {
|
||||||
|
await this.pointService.grant(userId, `预约${course.name}`, course.pointsBack, 'pending', {
|
||||||
|
referenceType: 'appointment',
|
||||||
|
referenceId: appointment.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return appointment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CourseService }
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
const { repository } = require('../repositories')
|
||||||
|
const { PointService } = require('./pointService')
|
||||||
|
const { ReferralService } = require('./referralService')
|
||||||
|
const { CourseService } = require('./courseService')
|
||||||
|
const { AdminService } = require('./adminService')
|
||||||
|
const { AuthService } = require('./authService')
|
||||||
|
const { SmsService } = require('./smsService')
|
||||||
|
|
||||||
|
const pointService = new PointService(repository)
|
||||||
|
const smsService = new SmsService()
|
||||||
|
const referralService = new ReferralService(repository, pointService, smsService)
|
||||||
|
const courseService = new CourseService(repository, pointService)
|
||||||
|
const adminService = new AdminService(repository, pointService)
|
||||||
|
const authService = new AuthService(repository, pointService, smsService)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
repository,
|
||||||
|
smsService,
|
||||||
|
pointService,
|
||||||
|
referralService,
|
||||||
|
courseService,
|
||||||
|
adminService,
|
||||||
|
authService,
|
||||||
|
}
|
||||||
@ -0,0 +1,646 @@
|
|||||||
|
const { createId } = require('../utils/ids')
|
||||||
|
|
||||||
|
const REDEMPTION_STATUS_LABELS = {
|
||||||
|
pending_review: '待审核',
|
||||||
|
approved: '已发券',
|
||||||
|
rejected: '已驳回',
|
||||||
|
used: '已核销',
|
||||||
|
expired: '已过期',
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOUCHER_STATUS_LABELS = {
|
||||||
|
not_issued: '未发券',
|
||||||
|
issued: '待核销',
|
||||||
|
used: '已核销',
|
||||||
|
void: '已作废',
|
||||||
|
expired: '已过期',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTime(value = new Date()) {
|
||||||
|
return new Date(value).toISOString().slice(0, 16).replace('T', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(days) {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() + days)
|
||||||
|
return toDateTime(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPagination(filters = {}) {
|
||||||
|
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
|
||||||
|
return { pageNum, pageSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate(list = [], filters = {}) {
|
||||||
|
const { pageNum, pageSize } = readPagination(filters)
|
||||||
|
const start = (pageNum - 1) * pageSize
|
||||||
|
return {
|
||||||
|
list: list.slice(start, start + pageSize),
|
||||||
|
total: list.length,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PointService {
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCenter(userId) {
|
||||||
|
const redemptions = await this.repository.listRedemptions({ userId })
|
||||||
|
return {
|
||||||
|
account: await this.repository.getPointAccount(userId),
|
||||||
|
transactions: await this.repository.getPointTransactions(userId),
|
||||||
|
rewards: await this.repository.getRewards(),
|
||||||
|
redemptions: redemptions.map((item) => this.decorateRedemption(item)),
|
||||||
|
expiryRule: await this.repository.getPointExpiryRule(),
|
||||||
|
expirySchedules: await this.repository.listPointExpirySchedules(userId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserRewards(userId, filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const rewards = await this.repository.listRewards(filters)
|
||||||
|
const filtered = rewards.filter((reward) => {
|
||||||
|
const matchKeyword = !keyword || [reward.name, reward.type, reward.description].some((value) => String(value || '').includes(keyword))
|
||||||
|
return matchKeyword
|
||||||
|
})
|
||||||
|
return paginate(filtered, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserTransactions(userId, filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const transactions = await this.repository.getPointTransactions(userId)
|
||||||
|
const filtered = transactions.filter((item) => {
|
||||||
|
const matchKeyword = !keyword || [item.title, item.createdAt, item.status].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchStatus = !filters.status || filters.status === 'all' || item.status === filters.status
|
||||||
|
return matchKeyword && matchStatus
|
||||||
|
})
|
||||||
|
return paginate(filtered, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserRedemptions(userId, filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const redemptions = (await this.repository.listRedemptions({ userId })).map((item) => this.decorateRedemption(item))
|
||||||
|
const filtered = redemptions.filter((item) => {
|
||||||
|
const matchKeyword = !keyword || [item.id, item.reward?.name, item.voucherCode, item.statusLabel, item.rejectedReason].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchStatus = !filters.status || filters.status === 'all' || item.status === filters.status
|
||||||
|
return matchKeyword && matchStatus
|
||||||
|
})
|
||||||
|
return paginate(filtered, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminManager() {
|
||||||
|
const parentUsers = await this.repository.listParentUsers()
|
||||||
|
const accounts = await Promise.all(parentUsers.map(async (user) => ({
|
||||||
|
user,
|
||||||
|
points: await this.repository.getPointAccount(user.id),
|
||||||
|
})))
|
||||||
|
const rawTransactions = await this.repository.listPointTransactions()
|
||||||
|
const transactions = await Promise.all(rawTransactions.map(async (transaction) => ({
|
||||||
|
...transaction,
|
||||||
|
userName: (await this.repository.findUserById(transaction.userId))?.name || transaction.userId,
|
||||||
|
})))
|
||||||
|
const rawExpirySchedules = await this.repository.listPointExpirySchedules()
|
||||||
|
const expirySchedules = await Promise.all(rawExpirySchedules.map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
userName: (await this.repository.findUserById(item.userId))?.name || item.userId,
|
||||||
|
})))
|
||||||
|
const redemptions = await this.repository.listRedemptions()
|
||||||
|
const overview = accounts.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
result.available += item.points.available
|
||||||
|
result.pending += item.points.pending
|
||||||
|
result.frozen += item.points.frozen
|
||||||
|
result.expiring += item.points.expiring
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ available: 0, pending: 0, frozen: 0, expiring: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview: {
|
||||||
|
...overview,
|
||||||
|
parents: accounts.length,
|
||||||
|
rewards: (await this.repository.getRewards()).length,
|
||||||
|
transactions: transactions.length,
|
||||||
|
pendingRedemptions: redemptions.filter((item) => item.status === 'pending_review').length,
|
||||||
|
issuedVouchers: redemptions.filter((item) => item.voucherStatus === 'issued').length,
|
||||||
|
nextExpireAt: expirySchedules[0]?.expireAt || '',
|
||||||
|
},
|
||||||
|
accounts,
|
||||||
|
transactions,
|
||||||
|
expiryRule: await this.repository.getPointExpiryRule(),
|
||||||
|
expirySchedules,
|
||||||
|
rewards: await this.repository.getRewards(),
|
||||||
|
redemptions: redemptions.slice(0, 8).map((item) => this.decorateRedemption(item)),
|
||||||
|
rules: await this.repository.getReferralRules(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPointAccounts(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const parentUsers = await this.repository.listParentUsers()
|
||||||
|
const rows = await Promise.all(parentUsers.map(async (user) => ({
|
||||||
|
user,
|
||||||
|
points: await this.repository.getPointAccount(user.id),
|
||||||
|
})))
|
||||||
|
const filtered = rows.filter((item) => {
|
||||||
|
const matchKeyword = !keyword || [item.user.name, item.user.phone, item.user.id, item.user.child?.name].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchStatus = !filters.pointStatus
|
||||||
|
|| filters.pointStatus === 'all'
|
||||||
|
|| (filters.pointStatus === 'available' && item.points.available > 0)
|
||||||
|
|| (filters.pointStatus === 'pending' && item.points.pending > 0)
|
||||||
|
|| (filters.pointStatus === 'expiring' && item.points.expiring > 0)
|
||||||
|
|| (filters.pointStatus === 'frozen' && item.points.frozen > 0)
|
||||||
|
return matchKeyword && matchStatus
|
||||||
|
})
|
||||||
|
const overview = filtered.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
result.available += item.points.available
|
||||||
|
result.pending += item.points.pending
|
||||||
|
result.frozen += item.points.frozen
|
||||||
|
result.expiring += item.points.expiring
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ available: 0, pending: 0, frozen: 0, expiring: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...paginate(filtered, filters),
|
||||||
|
overview: {
|
||||||
|
...overview,
|
||||||
|
parents: filtered.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPointTransactions(filters = {}) {
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const startDate = String(filters.startDate || '').trim()
|
||||||
|
const endDate = String(filters.endDate || '').trim()
|
||||||
|
const rawTransactions = await this.repository.listPointTransactions()
|
||||||
|
const rows = await Promise.all(rawTransactions.map(async (transaction) => ({
|
||||||
|
...transaction,
|
||||||
|
userName: (await this.repository.findUserById(transaction.userId))?.name || transaction.userId,
|
||||||
|
})))
|
||||||
|
const filtered = rows.filter((item) => {
|
||||||
|
const matchUser = !filters.userId || item.userId === filters.userId
|
||||||
|
const matchStatus = !filters.status || filters.status === 'all' || item.status === filters.status
|
||||||
|
const matchDate = (!startDate || item.createdAt >= startDate) && (!endDate || item.createdAt <= endDate)
|
||||||
|
const matchKeyword = !keyword || [item.userName, item.title, item.userId].some((value) => String(value || '').includes(keyword))
|
||||||
|
return matchUser && matchStatus && matchDate && matchKeyword
|
||||||
|
})
|
||||||
|
const overview = filtered.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
if (item.status === 'pending') result.pending += item.amount
|
||||||
|
if (item.status !== 'pending' && item.amount > 0) result.income += item.amount
|
||||||
|
if (item.status !== 'pending' && item.amount < 0) result.expense += Math.abs(item.amount)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ income: 0, expense: 0, pending: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...paginate(filtered, filters),
|
||||||
|
overview,
|
||||||
|
expiryRule: await this.repository.getPointExpiryRule(),
|
||||||
|
expirySchedules: await this.listPointExpirySchedules(filters),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPointExpirySchedules(filters = {}) {
|
||||||
|
const keyword = String(filters.expiryKeyword || filters.keyword || '').trim()
|
||||||
|
const rawExpirySchedules = await this.repository.listPointExpirySchedules(filters.userId)
|
||||||
|
const rows = await Promise.all(rawExpirySchedules.map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
userName: (await this.repository.findUserById(item.userId))?.name || item.userId,
|
||||||
|
})))
|
||||||
|
return rows.filter((item) => {
|
||||||
|
const matchKeyword = !keyword || [item.userName, item.source, item.userId].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchDate = (!filters.expireStart || item.expireAt >= filters.expireStart) && (!filters.expireEnd || item.expireAt <= filters.expireEnd)
|
||||||
|
return matchKeyword && matchDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReferralRule(event) {
|
||||||
|
const rules = await this.repository.getReferralRules()
|
||||||
|
return rules.find((rule) => rule.event === event) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeReferralRulePayload(payload = {}) {
|
||||||
|
const patch = {}
|
||||||
|
if (payload.title !== undefined) patch.title = String(payload.title).trim()
|
||||||
|
if (payload.inviter !== undefined) patch.inviter = Number(payload.inviter)
|
||||||
|
if (payload.invitee !== undefined) patch.invitee = Number(payload.invitee)
|
||||||
|
if (!patch.title) {
|
||||||
|
const error = new Error('规则名称不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
for (const key of ['inviter', 'invitee']) {
|
||||||
|
if (!Number.isFinite(patch[key]) || patch[key] < 0) {
|
||||||
|
const error = new Error('奖励积分必须是不小于 0 的数字')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
patch[key] = Math.round(patch[key])
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateReferralRule(event, payload = {}) {
|
||||||
|
const rule = await this.repository.updateReferralRule(event, this.normalizeReferralRulePayload(payload))
|
||||||
|
if (!rule) {
|
||||||
|
const error = new Error('推荐奖励规则不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRewards(filters = {}) {
|
||||||
|
const rewards = await this.repository.listRewards(filters)
|
||||||
|
const overview = rewards.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
result.total += 1
|
||||||
|
if (item.stock > 0) result.inStock += 1
|
||||||
|
if (item.stock <= 0) result.soldOut += 1
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ total: 0, inStock: 0, soldOut: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...paginate(rewards, filters),
|
||||||
|
overview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRewardDetail(id) {
|
||||||
|
const reward = await this.repository.findReward(id)
|
||||||
|
if (!reward) {
|
||||||
|
const error = new Error('兑换权益不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const redemptions = (await this.repository.listRedemptions({ itemId: id })).slice(0, 10).map((item) => this.decorateRedemption(item))
|
||||||
|
return { reward, redemptions }
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeRewardPayload(payload = {}) {
|
||||||
|
const reward = {}
|
||||||
|
if (payload.name !== undefined) reward.name = String(payload.name).trim()
|
||||||
|
if (payload.type !== undefined) reward.type = String(payload.type).trim()
|
||||||
|
if (payload.cost !== undefined) reward.cost = Number(payload.cost)
|
||||||
|
if (payload.stock !== undefined) reward.stock = Number(payload.stock)
|
||||||
|
if (payload.description !== undefined) reward.description = String(payload.description || '').trim()
|
||||||
|
if (!reward.name) {
|
||||||
|
const error = new Error('兑换权益名称不能为空')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!reward.type) reward.type = '课程权益'
|
||||||
|
if (!Number.isFinite(reward.cost) || reward.cost < 0) {
|
||||||
|
const error = new Error('兑换积分无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(reward.stock) || reward.stock < 0) {
|
||||||
|
const error = new Error('库存数量无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return reward
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReward(payload = {}) {
|
||||||
|
return this.repository.addReward({
|
||||||
|
id: createId('reward'),
|
||||||
|
description: '',
|
||||||
|
...this.normalizeRewardPayload(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async grant(userId, title, amount, status = 'available', reference = {}) {
|
||||||
|
const account = await this.repository.getPointAccount(userId)
|
||||||
|
const target = status === 'pending' ? 'pending' : 'available'
|
||||||
|
account[target] += Number(amount)
|
||||||
|
await this.repository.updatePointAccount(userId, account)
|
||||||
|
return this.repository.addPointTransaction({
|
||||||
|
id: createId('pt'),
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
amount,
|
||||||
|
status,
|
||||||
|
referenceType: reference.referenceType || '',
|
||||||
|
referenceId: reference.referenceId || '',
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async adjust(userId, amount, title, status = 'available') {
|
||||||
|
const numericAmount = Number(amount)
|
||||||
|
if (!Number.isFinite(numericAmount) || numericAmount === 0) {
|
||||||
|
const error = new Error('积分调整数量无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const user = await this.repository.findUserById(userId)
|
||||||
|
if (!user || user.role !== 'parent') {
|
||||||
|
const error = new Error('家长账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const target = status === 'pending' ? 'pending' : 'available'
|
||||||
|
const account = await this.repository.getPointAccount(userId)
|
||||||
|
if (account[target] + numericAmount < 0) {
|
||||||
|
const error = new Error('调整后积分不能小于 0')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
account[target] += numericAmount
|
||||||
|
await this.repository.updatePointAccount(userId, account)
|
||||||
|
return this.repository.addPointTransaction({
|
||||||
|
id: createId('pt'),
|
||||||
|
userId,
|
||||||
|
title: title || '后台手动调整',
|
||||||
|
amount: numericAmount,
|
||||||
|
status: target,
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async settlePending(userId, amount, title = '手动确认到账', reference = {}) {
|
||||||
|
const numericAmount = Number(amount)
|
||||||
|
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
|
||||||
|
const error = new Error('到账积分数量无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const user = await this.repository.findUserById(userId)
|
||||||
|
if (!user || user.role !== 'parent') {
|
||||||
|
const error = new Error('家长账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const account = await this.repository.getPointAccount(userId)
|
||||||
|
if (account.pending < numericAmount) {
|
||||||
|
const error = new Error('待到账积分不足')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
account.pending -= numericAmount
|
||||||
|
account.available += numericAmount
|
||||||
|
await this.repository.updatePointAccount(userId, account)
|
||||||
|
|
||||||
|
const pendingTransaction = await this.repository.addPointTransaction({
|
||||||
|
id: createId('pt'),
|
||||||
|
userId,
|
||||||
|
title: `${title || '手动确认到账'}(待到账转出)`,
|
||||||
|
amount: -numericAmount,
|
||||||
|
status: 'pending',
|
||||||
|
referenceType: reference.referenceType || '',
|
||||||
|
referenceId: reference.referenceId || '',
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
const transaction = await this.repository.addPointTransaction({
|
||||||
|
id: createId('pt'),
|
||||||
|
userId,
|
||||||
|
title: `${title || '手动确认到账'}(已到账)`,
|
||||||
|
amount: numericAmount,
|
||||||
|
status: 'available',
|
||||||
|
referenceType: reference.referenceType || '',
|
||||||
|
referenceId: reference.referenceId || '',
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
pendingTransaction,
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async settlePendingReference(userId, referenceType, referenceId, title) {
|
||||||
|
if (!referenceType || !referenceId) return null
|
||||||
|
const transactions = await this.repository.getPointTransactions(userId)
|
||||||
|
const amount = transactions
|
||||||
|
.filter((item) => item.status === 'pending' && item.referenceType === referenceType && item.referenceId === referenceId)
|
||||||
|
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
|
||||||
|
if (amount <= 0) return null
|
||||||
|
return this.settlePending(userId, amount, title, { referenceType, referenceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateReward(id, payload) {
|
||||||
|
const patch = {}
|
||||||
|
if (payload.name !== undefined) patch.name = String(payload.name).trim()
|
||||||
|
if (payload.type !== undefined) patch.type = String(payload.type).trim()
|
||||||
|
if (payload.cost !== undefined) patch.cost = Number(payload.cost)
|
||||||
|
if (payload.stock !== undefined) patch.stock = Number(payload.stock)
|
||||||
|
if (payload.description !== undefined) patch.description = String(payload.description || '').trim()
|
||||||
|
if (patch.cost !== undefined && (!Number.isFinite(patch.cost) || patch.cost < 0)) {
|
||||||
|
const error = new Error('兑换积分无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (patch.stock !== undefined && (!Number.isFinite(patch.stock) || patch.stock < 0)) {
|
||||||
|
const error = new Error('库存数量无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const reward = await this.repository.updateReward(id, patch)
|
||||||
|
if (!reward) {
|
||||||
|
const error = new Error('兑换权益不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return reward
|
||||||
|
}
|
||||||
|
|
||||||
|
async redeem(userId, itemId) {
|
||||||
|
const user = await this.repository.findUserById(userId)
|
||||||
|
if (!user || user.role !== 'parent') {
|
||||||
|
const error = new Error('家长账号不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const reward = await this.repository.findReward(itemId)
|
||||||
|
if (!reward) {
|
||||||
|
const error = new Error('兑换权益不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await this.repository.getPointAccount(userId)
|
||||||
|
if (account.available < reward.cost) {
|
||||||
|
const error = new Error('可用积分不足')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (Number(reward.stock || 0) <= 0) {
|
||||||
|
const error = new Error('兑换权益库存不足')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
account.available -= reward.cost
|
||||||
|
await this.repository.updatePointAccount(userId, account)
|
||||||
|
await this.repository.updateReward(reward.id, { stock: reward.stock - 1 })
|
||||||
|
await this.repository.addPointTransaction({
|
||||||
|
id: createId('pt'),
|
||||||
|
userId,
|
||||||
|
title: `兑换${reward.name}`,
|
||||||
|
amount: -reward.cost,
|
||||||
|
status: 'available',
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.repository.addRedemption({
|
||||||
|
id: createId('redeem'),
|
||||||
|
userId,
|
||||||
|
itemId,
|
||||||
|
costPoints: reward.cost,
|
||||||
|
status: 'pending_review',
|
||||||
|
voucherStatus: 'not_issued',
|
||||||
|
createdAt: toDateTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRedemptions(filters = {}) {
|
||||||
|
const list = await this.repository.listRedemptions(filters)
|
||||||
|
const overviewFilters = {
|
||||||
|
...filters,
|
||||||
|
status: 'all',
|
||||||
|
voucherStatus: 'all',
|
||||||
|
}
|
||||||
|
const overviewList = await this.repository.listRedemptions(overviewFilters)
|
||||||
|
const overview = overviewList.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
result.total += 1
|
||||||
|
result[item.status] = (result[item.status] || 0) + 1
|
||||||
|
if (item.voucherStatus === 'issued') result.issuedVouchers += 1
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ total: 0, pending_review: 0, approved: 0, rejected: 0, used: 0, expired: 0, issuedVouchers: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...paginate(list.map((item) => this.decorateRedemption(item)), filters),
|
||||||
|
overview,
|
||||||
|
statusLabels: REDEMPTION_STATUS_LABELS,
|
||||||
|
voucherStatusLabels: VOUCHER_STATUS_LABELS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decorateRedemption(order) {
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
statusLabel: REDEMPTION_STATUS_LABELS[order.status] || order.status,
|
||||||
|
voucherStatusLabel: VOUCHER_STATUS_LABELS[order.voucherStatus] || order.voucherStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createVoucherCode() {
|
||||||
|
const time = Date.now().toString(36).toUpperCase()
|
||||||
|
const random = Math.random().toString(36).slice(2, 6).toUpperCase()
|
||||||
|
return `LFH-${time}-${random}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveRedemption(id, operatorId, payload = {}) {
|
||||||
|
const order = await this.repository.findRedemptionById(id)
|
||||||
|
if (!order) {
|
||||||
|
const error = new Error('兑换申请不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (order.status !== 'pending_review') {
|
||||||
|
const error = new Error('只有待审核申请可以通过')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let voucherCode = this.createVoucherCode()
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
const exists = await this.repository.findRedemptionByVoucherCode(voucherCode)
|
||||||
|
if (!exists) break
|
||||||
|
voucherCode = this.createVoucherCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const expireAt = payload.expireAt ? String(payload.expireAt) : addDays(Number(payload.validDays || 30))
|
||||||
|
return this.decorateRedemption(await this.repository.updateRedemption(id, {
|
||||||
|
status: 'approved',
|
||||||
|
voucherCode,
|
||||||
|
voucherStatus: 'issued',
|
||||||
|
reviewedBy: operatorId || '',
|
||||||
|
reviewedAt: toDateTime(),
|
||||||
|
expireAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectRedemption(id, operatorId, payload = {}) {
|
||||||
|
const order = await this.repository.findRedemptionById(id)
|
||||||
|
if (!order) {
|
||||||
|
const error = new Error('兑换申请不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!['pending_review', 'approved'].includes(order.status) || order.voucherStatus === 'used') {
|
||||||
|
const error = new Error('当前状态不能驳回')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await this.repository.getPointAccount(order.userId)
|
||||||
|
const refund = Number(order.costPoints || order.reward?.cost || 0)
|
||||||
|
if (refund > 0) {
|
||||||
|
account.available += refund
|
||||||
|
await this.repository.updatePointAccount(order.userId, account)
|
||||||
|
await this.repository.addPointTransaction({
|
||||||
|
id: createId('pt'),
|
||||||
|
userId: order.userId,
|
||||||
|
title: `兑换驳回退还${order.reward?.name || '学习权益'}`,
|
||||||
|
amount: refund,
|
||||||
|
status: 'available',
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.decorateRedemption(await this.repository.updateRedemption(id, {
|
||||||
|
status: 'rejected',
|
||||||
|
voucherStatus: 'void',
|
||||||
|
reviewedBy: operatorId || '',
|
||||||
|
reviewedAt: toDateTime(),
|
||||||
|
rejectedReason: String(payload.reason || '审核未通过').trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyVoucher(payload = {}) {
|
||||||
|
const id = String(payload.id || '').trim()
|
||||||
|
const code = String(payload.code || '').trim().toUpperCase()
|
||||||
|
const order = id ? await this.repository.findRedemptionById(id) : await this.repository.findRedemptionByVoucherCode(code)
|
||||||
|
if (!order) {
|
||||||
|
const error = new Error('券码不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (order.status !== 'approved' || order.voucherStatus !== 'issued') {
|
||||||
|
const error = new Error('当前券码不能核销')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (order.expireAt && new Date(order.expireAt.replace(' ', 'T')).getTime() < Date.now()) {
|
||||||
|
await this.repository.updateRedemption(order.id, { status: 'expired', voucherStatus: 'expired' })
|
||||||
|
const error = new Error('券码已过期')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.decorateRedemption(await this.repository.updateRedemption(order.id, {
|
||||||
|
status: 'used',
|
||||||
|
voucherStatus: 'used',
|
||||||
|
usedAt: toDateTime(),
|
||||||
|
usedBy: String(payload.operatorId || '').trim(),
|
||||||
|
verifyNote: String(payload.note || '现场核销').trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PointService }
|
||||||
@ -0,0 +1,254 @@
|
|||||||
|
const { createId } = require('../utils/ids')
|
||||||
|
const { env } = require('../config/env')
|
||||||
|
const { createSessionToken } = require('../utils/sessionToken')
|
||||||
|
|
||||||
|
let wechatTokenCache = {
|
||||||
|
token: '',
|
||||||
|
expiresAt: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPagination(filters = {}) {
|
||||||
|
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
|
||||||
|
return { pageNum, pageSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate(list = [], filters = {}) {
|
||||||
|
const { pageNum, pageSize } = readPagination(filters)
|
||||||
|
const start = (pageNum - 1) * pageSize
|
||||||
|
return {
|
||||||
|
list: list.slice(start, start + pageSize),
|
||||||
|
total: list.length,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReferralService {
|
||||||
|
constructor(repository, pointService, smsService) {
|
||||||
|
this.repository = repository
|
||||||
|
this.pointService = pointService
|
||||||
|
this.smsService = smsService
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCenter(inviterId, filters = {}) {
|
||||||
|
if (!inviterId) {
|
||||||
|
const error = new Error('缺少推荐人')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const records = await this.repository.getReferralsByInviter(inviterId)
|
||||||
|
const keyword = String(filters.keyword || '').trim()
|
||||||
|
const status = String(filters.status || '').trim()
|
||||||
|
const filteredRecords = records.filter((item) => {
|
||||||
|
const matchKeyword = !keyword || [item.name, item.phone, item.childGrade, item.channel, item.status].some((value) => String(value || '').includes(keyword))
|
||||||
|
const matchStatus = !status || status === 'all' || item.status === status
|
||||||
|
return matchKeyword && matchStatus
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
stats: this.getStats(records),
|
||||||
|
rules: (await this.repository.getReferralRules()).map(({ title, inviter, invitee }) => ({ title, inviter, invitee })),
|
||||||
|
records: String(filters.paged || '') === '1' ? paginate(filteredRecords, filters) : filteredRecords,
|
||||||
|
share: await this.createShare('默认', inviterId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createShare(channel, inviterId) {
|
||||||
|
if (!inviterId) {
|
||||||
|
const error = new Error('缺少推荐人')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const inviter = await this.repository.findUserById(inviterId)
|
||||||
|
if (!inviter) {
|
||||||
|
const error = new Error('推荐人不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: `${inviter.name}邀请你领取立方和教育试听积分`,
|
||||||
|
channel,
|
||||||
|
path: `/pages/login/index?inviteCode=${inviter.inviterCode}`,
|
||||||
|
posterText: '扫码领取试听积分',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMiniProgramCode(inviterId) {
|
||||||
|
if (!inviterId) {
|
||||||
|
const error = new Error('缺少推荐人')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const inviter = await this.repository.findUserById(inviterId)
|
||||||
|
if (!inviter) {
|
||||||
|
const error = new Error('推荐人不存在')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!env.wechat.appId || !env.wechat.appSecret) {
|
||||||
|
const error = new Error('微信小程序码尚未配置 AppID 和 AppSecret')
|
||||||
|
error.status = 501
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await this.getWechatAccessToken()
|
||||||
|
const response = await fetch(`https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
scene: `invite=${inviter.inviterCode}`,
|
||||||
|
page: 'pages/login/index',
|
||||||
|
check_path: false,
|
||||||
|
env_version: env.wechat.envVersion || 'release',
|
||||||
|
width: 430,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
const data = await response.json()
|
||||||
|
const error = new Error(data.errmsg || '微信小程序码生成失败')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error('微信小程序码生成失败')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return {
|
||||||
|
inviterCode: inviter.inviterCode,
|
||||||
|
path: `/pages/login/index?inviteCode=${inviter.inviterCode}`,
|
||||||
|
image: `data:image/png;base64,${buffer.toString('base64')}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWechatAccessToken() {
|
||||||
|
if (wechatTokenCache.token && Date.now() < wechatTokenCache.expiresAt) return wechatTokenCache.token
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'client_credential',
|
||||||
|
appid: env.wechat.appId,
|
||||||
|
secret: env.wechat.appSecret,
|
||||||
|
})
|
||||||
|
const response = await fetch(`https://api.weixin.qq.com/cgi-bin/token?${params.toString()}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok || data.errcode || !data.access_token) {
|
||||||
|
const error = new Error(data.errmsg || '微信 access_token 获取失败')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
wechatTokenCache = {
|
||||||
|
token: data.access_token,
|
||||||
|
expiresAt: Date.now() + Math.max(60, Number(data.expires_in || 7200) - 300) * 1000,
|
||||||
|
}
|
||||||
|
return wechatTokenCache.token
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindReferral(payload) {
|
||||||
|
const inviterCode = String(payload.inviterCode || '').trim().toUpperCase()
|
||||||
|
const parentName = String(payload.parentName || '').trim()
|
||||||
|
const phone = String(payload.phone || '').trim()
|
||||||
|
const code = String(payload.code || '').trim()
|
||||||
|
const childName = String(payload.childName || '').trim()
|
||||||
|
const grade = String(payload.grade || '').trim()
|
||||||
|
if (!parentName || !/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('家长姓名和正确手机号必填')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!this.smsService) {
|
||||||
|
const error = new Error('短信服务未启用')
|
||||||
|
error.status = 501
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.smsService.verifyCode(phone, code, 'referral_register')
|
||||||
|
|
||||||
|
const inviter = await this.repository.findUserByInviteCode(inviterCode)
|
||||||
|
if (!inviter) {
|
||||||
|
const error = new Error('推荐码无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const existed = await this.repository.findUserByLoginAccount(phone)
|
||||||
|
if (existed) {
|
||||||
|
const error = new Error('该手机号已注册,不能重复领取新客奖励')
|
||||||
|
error.status = 409
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitee = await this.repository.addUser({
|
||||||
|
id: createId('parent'),
|
||||||
|
username: phone,
|
||||||
|
passwordHash: null,
|
||||||
|
role: 'parent',
|
||||||
|
roleName: '家长',
|
||||||
|
name: parentName,
|
||||||
|
phone,
|
||||||
|
status: 'active',
|
||||||
|
inviterCode: `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
|
||||||
|
child: { id: createId('child'), name: childName || '未填写', grade: grade || '待确认', focus: '待测评' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rule = await this.pointService.getReferralRule('registered') || { inviter: 20, invitee: 20 }
|
||||||
|
const referral = await this.repository.addReferral({
|
||||||
|
id: createId('rel'),
|
||||||
|
inviterId: inviter.id,
|
||||||
|
inviteeId: invitee.id,
|
||||||
|
name: parentName,
|
||||||
|
phone,
|
||||||
|
childGrade: grade || '待确认',
|
||||||
|
status: '已注册',
|
||||||
|
reward: rule.inviter,
|
||||||
|
channel: 'H5/小程序',
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.repository.addLead({
|
||||||
|
id: createId('lead'),
|
||||||
|
referralId: referral.id,
|
||||||
|
inviteeId: invitee.id,
|
||||||
|
parent: parentName,
|
||||||
|
source: inviter.name,
|
||||||
|
stage: '已注册',
|
||||||
|
advisor: '未分配',
|
||||||
|
risk: '正常',
|
||||||
|
channel: referral.channel,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rule.inviter > 0) await this.pointService.grant(inviter.id, `${parentName}完成注册`, rule.inviter)
|
||||||
|
if (rule.invitee > 0) await this.pointService.grant(invitee.id, '新家长注册奖励', rule.invitee)
|
||||||
|
|
||||||
|
const safeInvitee = await this.repository.withPermissions(invitee)
|
||||||
|
return {
|
||||||
|
referral,
|
||||||
|
invitee: {
|
||||||
|
...safeInvitee,
|
||||||
|
sessionToken: createSessionToken(safeInvitee.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(records) {
|
||||||
|
const stageRank = {
|
||||||
|
待注册: 0,
|
||||||
|
已注册: 1,
|
||||||
|
已预约: 2,
|
||||||
|
已到课: 3,
|
||||||
|
已报名: 4,
|
||||||
|
}
|
||||||
|
const reached = (status) => records.filter((item) => (stageRank[item.status] || 0) >= stageRank[status]).length
|
||||||
|
const purchased = reached('已报名')
|
||||||
|
return {
|
||||||
|
invited: records.length,
|
||||||
|
registered: reached('已注册'),
|
||||||
|
booked: reached('已预约'),
|
||||||
|
attended: reached('已到课'),
|
||||||
|
purchased,
|
||||||
|
conversion: records.length ? Math.round((purchased / records.length) * 100) : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ReferralService }
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const { env } = require('../config/env')
|
||||||
|
|
||||||
|
const VALID_PURPOSES = ['login', 'bind_wechat', 'update_phone', 'referral_register']
|
||||||
|
|
||||||
|
function nowSeconds() {
|
||||||
|
return Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAliyunTimestamp(date = new Date()) {
|
||||||
|
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentEncode(value) {
|
||||||
|
return encodeURIComponent(String(value))
|
||||||
|
.replace(/\+/g, '%20')
|
||||||
|
.replace(/\*/g, '%2A')
|
||||||
|
.replace(/%7E/g, '~')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRealConfig(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
return Boolean(text && !text.startsWith('your_') && text !== 'SMS_000000000')
|
||||||
|
}
|
||||||
|
|
||||||
|
class SmsService {
|
||||||
|
constructor(options = env.sms) {
|
||||||
|
this.options = options
|
||||||
|
this.codes = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePhone(phone) {
|
||||||
|
return String(phone || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPhone(phone) {
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
const error = new Error('手机号格式不正确')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePurpose(purpose) {
|
||||||
|
const value = String(purpose || 'login').trim()
|
||||||
|
if (!VALID_PURPOSES.includes(value)) {
|
||||||
|
const error = new Error('验证码用途无效')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey(phone, purpose) {
|
||||||
|
return `${purpose}:${phone}`
|
||||||
|
}
|
||||||
|
|
||||||
|
createCode() {
|
||||||
|
const length = Math.max(4, Math.min(8, Number(this.options.codeLength || 6)))
|
||||||
|
const min = 10 ** (length - 1)
|
||||||
|
const max = (10 ** length) - 1
|
||||||
|
return String(crypto.randomInt(min, max + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendVerificationCode(payload = {}) {
|
||||||
|
const phone = this.normalizePhone(payload.phone)
|
||||||
|
const purpose = this.normalizePurpose(payload.purpose)
|
||||||
|
this.assertPhone(phone)
|
||||||
|
|
||||||
|
const key = this.cacheKey(phone, purpose)
|
||||||
|
const existed = this.codes.get(key)
|
||||||
|
const current = nowSeconds()
|
||||||
|
const cooldown = Math.max(0, Number(this.options.codeCooldownSeconds || 60))
|
||||||
|
if (existed && existed.sentAt + cooldown > current) {
|
||||||
|
const error = new Error(`验证码发送过于频繁,请 ${existed.sentAt + cooldown - current} 秒后再试`)
|
||||||
|
error.status = 429
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = this.createCode()
|
||||||
|
if (this.shouldUseAliyun()) {
|
||||||
|
await this.sendAliyunSms(phone, code)
|
||||||
|
this.codes.set(key, {
|
||||||
|
code,
|
||||||
|
sentAt: current,
|
||||||
|
expiresAt: current + Math.max(60, Number(this.options.codeTtlSeconds || 300)),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
phone,
|
||||||
|
purpose,
|
||||||
|
expiresIn: Number(this.options.codeTtlSeconds || 300),
|
||||||
|
cooldown,
|
||||||
|
message: '验证码已发送',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.allowTestCode) {
|
||||||
|
return {
|
||||||
|
phone,
|
||||||
|
purpose,
|
||||||
|
expiresIn: Number(this.options.codeTtlSeconds || 300),
|
||||||
|
cooldown,
|
||||||
|
message: '短信未配置,测试验证码可用',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error('短信服务未配置')
|
||||||
|
error.status = 501
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyCode(phoneValue, codeValue, purposeValue = 'login') {
|
||||||
|
const phone = this.normalizePhone(phoneValue)
|
||||||
|
const code = String(codeValue || '').trim()
|
||||||
|
const purpose = this.normalizePurpose(purposeValue)
|
||||||
|
this.assertPhone(phone)
|
||||||
|
|
||||||
|
if (this.options.allowTestCode && code === String(this.options.testCode || '111111')) return true
|
||||||
|
|
||||||
|
const key = this.cacheKey(phone, purpose)
|
||||||
|
const record = this.codes.get(key)
|
||||||
|
if (!record || record.expiresAt < nowSeconds() || record.code !== code) {
|
||||||
|
const error = new Error('验证码错误')
|
||||||
|
error.status = 401
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.codes.delete(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldUseAliyun() {
|
||||||
|
const aliyun = this.options.aliyun || {}
|
||||||
|
return String(this.options.provider || 'aliyun') === 'aliyun'
|
||||||
|
&& hasRealConfig(aliyun.accessKeyId)
|
||||||
|
&& hasRealConfig(aliyun.accessKeySecret)
|
||||||
|
&& hasRealConfig(aliyun.signName)
|
||||||
|
&& hasRealConfig(aliyun.templateCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAliyunSms(phone, code) {
|
||||||
|
const aliyun = this.options.aliyun || {}
|
||||||
|
const params = {
|
||||||
|
AccessKeyId: aliyun.accessKeyId,
|
||||||
|
Action: 'SendSms',
|
||||||
|
Format: 'JSON',
|
||||||
|
PhoneNumbers: phone,
|
||||||
|
RegionId: 'cn-hangzhou',
|
||||||
|
SignName: aliyun.signName,
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
SignatureNonce: crypto.randomUUID(),
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
TemplateCode: aliyun.templateCode,
|
||||||
|
TemplateParam: JSON.stringify({ [aliyun.templateParamKey || 'code']: code }),
|
||||||
|
Timestamp: toAliyunTimestamp(),
|
||||||
|
Version: '2017-05-25',
|
||||||
|
}
|
||||||
|
const canonicalQuery = Object.keys(params)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
|
||||||
|
.join('&')
|
||||||
|
const stringToSign = `GET&%2F&${percentEncode(canonicalQuery)}`
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha1', `${aliyun.accessKeySecret}&`)
|
||||||
|
.update(stringToSign)
|
||||||
|
.digest('base64')
|
||||||
|
const url = `https://${aliyun.endpoint || 'dysmsapi.aliyuncs.com'}/?Signature=${percentEncode(signature)}&${canonicalQuery}`
|
||||||
|
const response = await fetch(url, { method: 'GET' })
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || data.Code !== 'OK') {
|
||||||
|
const error = new Error(data.Message || '短信发送失败')
|
||||||
|
error.status = 502
|
||||||
|
error.providerCode = data.Code
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SmsService }
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
function createId(prefix) {
|
||||||
|
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createId }
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
function hashPassword(password) {
|
||||||
|
return crypto.createHash('sha256').update(String(password || '')).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, passwordHash) {
|
||||||
|
return Boolean(passwordHash) && hashPassword(password) === passwordHash
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const { env } = require('../config/env')
|
||||||
|
|
||||||
|
function sign(value) {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', env.auth.sessionSecret)
|
||||||
|
.update(value)
|
||||||
|
.digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionToken(accountId) {
|
||||||
|
const issuedAt = Math.floor(Date.now() / 1000)
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
sub: accountId,
|
||||||
|
iat: issuedAt,
|
||||||
|
exp: issuedAt + Math.max(1, Number(env.auth.sessionTtlHours || 168)) * 60 * 60,
|
||||||
|
})).toString('base64url')
|
||||||
|
return `${payload}.${sign(payload)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySessionToken(token) {
|
||||||
|
const [payload, signature, extra] = String(token || '').trim().split('.')
|
||||||
|
if (!payload || !signature || extra) return null
|
||||||
|
const expected = sign(payload)
|
||||||
|
const actualBuffer = Buffer.from(signature)
|
||||||
|
const expectedBuffer = Buffer.from(expected)
|
||||||
|
if (actualBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(actualBuffer, expectedBuffer)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
||||||
|
if (!data.sub || !data.exp || Number(data.exp) <= Math.floor(Date.now() / 1000)) return null
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSessionToken, verifySessionToken }
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: lifanghe-mysql
|
||||||
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root123456
|
||||||
|
MYSQL_DATABASE: lifanghe_education
|
||||||
|
MYSQL_USER: lifanghe
|
||||||
|
MYSQL_PASSWORD: lifanghe123
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./backend/sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ulifanghe", "-plifanghe123"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: lifanghe-backend
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: 3100
|
||||||
|
DB_CLIENT: mysql
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: 3306
|
||||||
|
MYSQL_USER: lifanghe
|
||||||
|
MYSQL_PASSWORD: lifanghe123
|
||||||
|
MYSQL_DATABASE: lifanghe_education
|
||||||
|
WECHAT_APP_ID: ${WECHAT_APP_ID:-}
|
||||||
|
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_node_modules:/app/node_modules
|
||||||
|
command: sh -c "npm install && npm start"
|
||||||
|
|
||||||
|
front-h5:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: lifanghe-front-h5
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: http://127.0.0.1:3100/api
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "5177:5177"
|
||||||
|
volumes:
|
||||||
|
- ./front:/app
|
||||||
|
- front_node_modules:/app/node_modules
|
||||||
|
command: sh -c "npm install && npm run dev:h5 -- --host 0.0.0.0 --port 5177"
|
||||||
|
|
||||||
|
pc-admin:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: lifanghe-pc-admin
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
VITE_APP_BASE_API: http://127.0.0.1:3100/api
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "5174:5174"
|
||||||
|
volumes:
|
||||||
|
- ./pc-admin:/app
|
||||||
|
- pc_admin_node_modules:/app/node_modules
|
||||||
|
command: sh -c "npm install && npx vite --host 0.0.0.0 --port 5174"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
backend_node_modules:
|
||||||
|
front_node_modules:
|
||||||
|
pc_admin_node_modules:
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3100/api
|
||||||
|
# VITE_API_BASE_URL=https://lfhedu.ruixininfo.com/api
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||||
|
CSS.supports('top: constant(a)'))
|
||||||
|
document.write(
|
||||||
|
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||||
|
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
|
</script>
|
||||||
|
<title></title>
|
||||||
|
<!--preload-links-->
|
||||||
|
<!--app-context-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "uni-preset-vue",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:custom": "uni -p",
|
||||||
|
"dev:h5": "uni",
|
||||||
|
"dev:h5:ssr": "uni --ssr",
|
||||||
|
"dev:mp-alipay": "uni -p mp-alipay",
|
||||||
|
"dev:mp-baidu": "uni -p mp-baidu",
|
||||||
|
"dev:mp-jd": "uni -p mp-jd",
|
||||||
|
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||||
|
"dev:mp-lark": "uni -p mp-lark",
|
||||||
|
"dev:mp-qq": "uni -p mp-qq",
|
||||||
|
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||||
|
"dev:mp-harmony": "uni -p mp-harmony",
|
||||||
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
|
"dev:mp-xhs": "uni -p mp-xhs",
|
||||||
|
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||||
|
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||||
|
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||||
|
"build:custom": "uni build -p",
|
||||||
|
"build:h5": "uni build",
|
||||||
|
"build:h5:ssr": "uni build --ssr",
|
||||||
|
"build:mp-alipay": "uni build -p mp-alipay",
|
||||||
|
"build:mp-baidu": "uni build -p mp-baidu",
|
||||||
|
"build:mp-jd": "uni build -p mp-jd",
|
||||||
|
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||||
|
"build:mp-lark": "uni build -p mp-lark",
|
||||||
|
"build:mp-qq": "uni build -p mp-qq",
|
||||||
|
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||||
|
"build:mp-harmony": "uni build -p mp-harmony",
|
||||||
|
"build:mp-weixin": "uni build -p mp-weixin",
|
||||||
|
"build:mp-xhs": "uni build -p mp-xhs",
|
||||||
|
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||||
|
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||||
|
"build:quickapp-webview-union": "uni build -p quickapp-webview-union"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-app-harmony": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-alipay": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-baidu": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-harmony": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-jd": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-kuaishou": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-lark": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-qq": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-toutiao": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-mp-xhs": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-quickapp-webview": "3.0.0-5000720260410001",
|
||||||
|
"uview-plus": "^3.4.41",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.1.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-5000720260410001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-5000720260410001",
|
||||||
|
"@vue/runtime-core": "^3.4.21",
|
||||||
|
"sass": "^1.89.2",
|
||||||
|
"vite": "5.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types='@dcloudio/types' />
|
||||||
|
import 'vue'
|
||||||
|
|
||||||
|
declare module '@vue/runtime-core' {
|
||||||
|
type Hooks = App.AppInstance & Page.PageInstance;
|
||||||
|
|
||||||
|
interface ComponentCustomOptions extends Hooks {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
onLaunch() {
|
||||||
|
console.log('LiFangHe Education app launched')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
page {
|
||||||
|
min-height: 100%;
|
||||||
|
color: #17312d;
|
||||||
|
background: #f3f6ed;
|
||||||
|
font-family: "Inter", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
view,
|
||||||
|
text,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: normal;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-tabbar-shell">
|
||||||
|
<view class="admin-tabbar-spacer"></view>
|
||||||
|
<view class="admin-tabbar">
|
||||||
|
<view
|
||||||
|
v-for="item in tabs"
|
||||||
|
:key="item.key"
|
||||||
|
:class="['admin-tabbar__item', active === item.key && 'admin-tabbar__item--active']"
|
||||||
|
@click="go(item)"
|
||||||
|
>
|
||||||
|
<image class="admin-tabbar__icon" :src="iconSrc(item)" mode="aspectFit"></image>
|
||||||
|
<text>{{ item.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
active: {
|
||||||
|
type: String,
|
||||||
|
default: 'home',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: [
|
||||||
|
{ key: 'home', label: '首页', icon: 'home', url: '/pages/admin/index' },
|
||||||
|
{ key: 'leads', label: '线索', icon: 'referral', url: '/pages/admin-leads/index' },
|
||||||
|
{ key: 'courses', label: '试听', icon: 'courses', url: '/pages/admin-courses/index' },
|
||||||
|
{ key: 'points', label: '积分', icon: 'points', url: '/pages/admin-points/index' },
|
||||||
|
{ key: 'profile', label: '我的', icon: 'account', url: '/pages/admin-profile/index' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
iconSrc(item) {
|
||||||
|
const suffix = this.active === item.key ? '-active' : ''
|
||||||
|
return `/static/tabbar/${item.icon}${suffix}.png`
|
||||||
|
},
|
||||||
|
go(item) {
|
||||||
|
if (this.active === item.key) return
|
||||||
|
uni.redirectTo({ url: item.url })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-tabbar-spacer {
|
||||||
|
height: calc(146rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabbar {
|
||||||
|
position: fixed;
|
||||||
|
right: 24rpx;
|
||||||
|
bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
left: 24rpx;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 14rpx;
|
||||||
|
border: 1rpx solid rgba(11, 79, 70, 0.1);
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: rgba(251, 252, 247, 0.96);
|
||||||
|
box-shadow: 0 18rpx 46rpx rgba(11, 47, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabbar__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 86rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabbar__icon {
|
||||||
|
width: 42rpx;
|
||||||
|
height: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabbar__item--active {
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #dff8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabbar__item text {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://lfhedu.ruixininfo.com/api'
|
||||||
|
|
||||||
|
export const POINT_STATUS = {
|
||||||
|
available: '可用',
|
||||||
|
pending: '待发放',
|
||||||
|
frozen: '冻结',
|
||||||
|
revoked: '已撤销'
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
createSSRApp
|
||||||
|
} from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import uviewPlus from "uview-plus";
|
||||||
|
import "uview-plus/index.scss";
|
||||||
|
import "./styles/theme.scss";
|
||||||
|
import UButton from "uview-plus/components/u-button/u-button.vue";
|
||||||
|
import UDatetimePicker from "uview-plus/components/u-datetime-picker/u-datetime-picker.vue";
|
||||||
|
import UGap from "uview-plus/components/u-gap/u-gap.vue";
|
||||||
|
import UIcon from "uview-plus/components/u-icon/u-icon.vue";
|
||||||
|
import UInput from "uview-plus/components/u-input/u-input.vue";
|
||||||
|
import ULoadingIcon from "uview-plus/components/u-loading-icon/u-loading-icon.vue";
|
||||||
|
import UOverlay from "uview-plus/components/u-overlay/u-overlay.vue";
|
||||||
|
import UPicker from "uview-plus/components/u-picker/u-picker.vue";
|
||||||
|
import UPopup from "uview-plus/components/u-popup/u-popup.vue";
|
||||||
|
import USafeBottom from "uview-plus/components/u-safe-bottom/u-safe-bottom.vue";
|
||||||
|
import UStatusBar from "uview-plus/components/u-status-bar/u-status-bar.vue";
|
||||||
|
import UTag from "uview-plus/components/u-tag/u-tag.vue";
|
||||||
|
import UToast from "uview-plus/components/u-toast/u-toast.vue";
|
||||||
|
import UToolbar from "uview-plus/components/u-toolbar/u-toolbar.vue";
|
||||||
|
import UTransition from "uview-plus/components/u-transition/u-transition.vue";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App);
|
||||||
|
app.use(uviewPlus);
|
||||||
|
app.component("u-button", UButton);
|
||||||
|
app.component("up-datetime-picker", UDatetimePicker);
|
||||||
|
app.component("u-gap", UGap);
|
||||||
|
app.component("u-icon", UIcon);
|
||||||
|
app.component("up-icon", UIcon);
|
||||||
|
app.component("up-input", UInput);
|
||||||
|
app.component("u-loading-icon", ULoadingIcon);
|
||||||
|
app.component("u-overlay", UOverlay);
|
||||||
|
app.component("u-picker", UPicker);
|
||||||
|
app.component("u-popup", UPopup);
|
||||||
|
app.component("u-safe-bottom", USafeBottom);
|
||||||
|
app.component("u-status-bar", UStatusBar);
|
||||||
|
app.component("u-tag", UTag);
|
||||||
|
app.component("u-toast", UToast);
|
||||||
|
app.component("u-toolbar", UToolbar);
|
||||||
|
app.component("u-transition", UTransition);
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"appid": "",
|
||||||
|
"description": "",
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"versionCode": "100",
|
||||||
|
"transformPx": false,
|
||||||
|
/* 5+App特有相关 */
|
||||||
|
"app-plus": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"nvueStyleCompiler": "uni-app",
|
||||||
|
"compilerVersion": 3,
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": true,
|
||||||
|
"autoclose": true,
|
||||||
|
"delay": 0
|
||||||
|
},
|
||||||
|
/* 模块配置 */
|
||||||
|
"modules": {},
|
||||||
|
/* 应用发布信息 */
|
||||||
|
"distribute": {
|
||||||
|
/* android打包配置 */
|
||||||
|
"android": {
|
||||||
|
"permissions": ["<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", "<uses-permission android:name=\"android.permission.VIBRATE\"/>", "<uses-permission android:name=\"android.permission.READ_LOGS\"/>", "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>", "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>", "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.CAMERA\"/>", "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>", "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>", "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>", "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>", "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>", "<uses-feature android:name=\"android.hardware.camera\"/>", "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"]
|
||||||
|
},
|
||||||
|
/* ios打包配置 */
|
||||||
|
"ios": {},
|
||||||
|
/* SDK配置 */
|
||||||
|
"sdkConfigs": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* 快应用特有相关 */
|
||||||
|
"quickapp": {},
|
||||||
|
/* 小程序特有相关 */
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "wx48e101cff8e91db9",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false
|
||||||
|
},
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-alipay": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-baidu": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-toutiao": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"uniStatistics": {
|
||||||
|
"enable": false
|
||||||
|
},
|
||||||
|
"vueVersion": "3"
|
||||||
|
}
|
||||||
@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"easycom": {
|
||||||
|
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "立方和教育",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/referral/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "推荐有礼",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/points/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "积分中心",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/courses/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "课程预约",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/account/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的账号",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "增长管理台",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-leads/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "线索管理",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-points/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "积分管理",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-point-records/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "积分流水",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-point-accounts/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "积分账户",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-courses/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "试听管理",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-course-edit/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "维护试听课",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-appointments/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "预约到课",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin-profile/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "管理账号",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/login/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "账号登录",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/campaign/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "成长邀请",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/courses-list/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "可约课程",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/referral-records/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "推荐记录",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/point-rewards/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "兑换权益",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/point-records/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "积分记录",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#6f7c78",
|
||||||
|
"selectedColor": "#0b4f46",
|
||||||
|
"backgroundColor": "#fbfcf7",
|
||||||
|
"borderStyle": "white",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"pagePath": "pages/index/index",
|
||||||
|
"text": "首页",
|
||||||
|
"iconPath": "static/tabbar/home.png",
|
||||||
|
"selectedIconPath": "static/tabbar/home-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/referral/index",
|
||||||
|
"text": "推荐",
|
||||||
|
"iconPath": "static/tabbar/referral.png",
|
||||||
|
"selectedIconPath": "static/tabbar/referral-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/points/index",
|
||||||
|
"text": "积分",
|
||||||
|
"iconPath": "static/tabbar/points.png",
|
||||||
|
"selectedIconPath": "static/tabbar/points-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/courses/index",
|
||||||
|
"text": "课程",
|
||||||
|
"iconPath": "static/tabbar/courses.png",
|
||||||
|
"selectedIconPath": "static/tabbar/courses-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/account/index",
|
||||||
|
"text": "我的",
|
||||||
|
"iconPath": "static/tabbar/account.png",
|
||||||
|
"selectedIconPath": "static/tabbar/account-active.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"navigationBarTitleText": "立方和教育",
|
||||||
|
"navigationBarBackgroundColor": "#0b2f2a",
|
||||||
|
"backgroundColor": "#f3f6ed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,346 @@
|
|||||||
|
<template>
|
||||||
|
<view class="lf-page point-records-page">
|
||||||
|
<view class="lf-nav">
|
||||||
|
<view>
|
||||||
|
<view class="lf-nav__title">积分流水</view>
|
||||||
|
<view class="lf-nav__sub">按家长、事项和状态查询积分变动</view>
|
||||||
|
</view>
|
||||||
|
<view class="lf-pill">{{ total }} 条</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!isAdmin" class="lf-card denied-card">
|
||||||
|
<view class="denied-card__title">当前账号没有管理权限</view>
|
||||||
|
<view class="lf-caption">请切换到管理账号后再查看积分流水。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="lf-card filter-card">
|
||||||
|
<view class="search-box">
|
||||||
|
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
|
||||||
|
<input v-model="filters.keyword" placeholder="搜索家长、账号或事项" confirm-type="search" @confirm="refresh" />
|
||||||
|
</view>
|
||||||
|
<view class="status-row">
|
||||||
|
<button
|
||||||
|
v-for="item in statusOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:class="['status-chip', filters.status === item.value && 'status-chip--active']"
|
||||||
|
@click="selectStatus(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="date-grid">
|
||||||
|
<view class="date-field">
|
||||||
|
<text>开始日期</text>
|
||||||
|
<input v-model="filters.startDate" placeholder="YYYY-MM-DD" confirm-type="done" />
|
||||||
|
</view>
|
||||||
|
<view class="date-field">
|
||||||
|
<text>结束日期</text>
|
||||||
|
<input v-model="filters.endDate" placeholder="YYYY-MM-DD" confirm-type="done" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="query-actions">
|
||||||
|
<button class="query-actions__ghost" @click="reset">重置</button>
|
||||||
|
<button class="query-actions__primary" @click="refresh">查询</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-for="tx in list" :key="tx.id" class="lf-card tx-card">
|
||||||
|
<view class="tx-card__top">
|
||||||
|
<view class="tx-card__main">
|
||||||
|
<view class="tx-card__title">{{ tx.title }}</view>
|
||||||
|
<view class="tx-card__meta">{{ tx.userName || tx.userId }} · {{ tx.createdAt }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="tx-card__amount">
|
||||||
|
<text :class="tx.amount < 0 && 'is-minus'">{{ tx.amount > 0 ? '+' : '' }}{{ tx.amount }}</text>
|
||||||
|
<text>{{ statusLabel(tx.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="tx-card__id">{{ tx.userId }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-state">{{ loadText }}</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<AdminTabbar v-if="isAdmin" active="points" />
|
||||||
|
<u-toast ref="toast"></u-toast>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AdminTabbar from '../../components/AdminTabbar.vue'
|
||||||
|
import { getAdminPointTransactions } from '../../services/educationApi'
|
||||||
|
import { getStoredAccount, isAdminAccount } from '../../services/accountSession'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { AdminTabbar },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
account: getStoredAccount() || {},
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
finished: false,
|
||||||
|
filters: {
|
||||||
|
keyword: '',
|
||||||
|
status: 'all',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
statusOptions: [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '可用', value: 'available' },
|
||||||
|
{ label: '待到账', value: 'pending' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isAdmin() {
|
||||||
|
return isAdminAccount(this.account)
|
||||||
|
},
|
||||||
|
loadText() {
|
||||||
|
if (this.loading) return '加载中...'
|
||||||
|
if (this.finished) return this.list.length ? '没有更多了' : '暂无积分流水'
|
||||||
|
return '上拉加载更多'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
const account = getStoredAccount()
|
||||||
|
if (!account) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.account = account
|
||||||
|
if (this.isAdmin && !this.list.length) this.refresh()
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
if (this.isAdmin) await this.refresh()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
this.loadMore()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectStatus(status) {
|
||||||
|
this.filters.status = status
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.filters.keyword = ''
|
||||||
|
this.filters.status = 'all'
|
||||||
|
this.filters.startDate = ''
|
||||||
|
this.filters.endDate = ''
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
this.filters.pageNum = 1
|
||||||
|
this.finished = false
|
||||||
|
await this.load(true)
|
||||||
|
},
|
||||||
|
async loadMore() {
|
||||||
|
if (this.loading || this.finished || !this.isAdmin) return
|
||||||
|
this.filters.pageNum += 1
|
||||||
|
await this.load(false)
|
||||||
|
},
|
||||||
|
async load(reset) {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const res = await getAdminPointTransactions(this.filters)
|
||||||
|
if (res.ok === false) {
|
||||||
|
this.$refs.toast.show({ message: res.message || '积分流水加载失败', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data || res || {}
|
||||||
|
const rows = data.list || []
|
||||||
|
this.total = data.total || 0
|
||||||
|
this.list = reset ? rows : this.list.concat(rows)
|
||||||
|
this.finished = this.list.length >= this.total
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
statusLabel(status) {
|
||||||
|
return status === 'pending' ? '待到账' : '可用'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.point-records-page {
|
||||||
|
padding-bottom: 170rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.denied-card,
|
||||||
|
.filter-card,
|
||||||
|
.tx-card {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.denied-card__title {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 74rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f3f8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
height: 54rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid #dce8dc;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 54rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip--active {
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #dff8ee;
|
||||||
|
border-color: #9ae9dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field text {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field input {
|
||||||
|
height: 68rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 1rpx solid #dce8dc;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #17312d;
|
||||||
|
background: #fbfcf7;
|
||||||
|
font-size: 23rpx;
|
||||||
|
line-height: 68rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.86fr 1.14fr;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions button {
|
||||||
|
height: 68rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 68rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions__ghost {
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #eef8f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions__primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0b4f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__title {
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 29rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__meta {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__amount {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 128rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__amount text:first-child {
|
||||||
|
color: #0b4f46;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__amount text:first-child.is-minus {
|
||||||
|
color: #ff7a59;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__amount text:last-child {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-card__id {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #eef8f4;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-state {
|
||||||
|
padding: 28rpx 0 90rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 23rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<view class="lf-page admin-page">
|
||||||
|
<view class="lf-nav">
|
||||||
|
<view>
|
||||||
|
<view class="lf-nav__title">增长管理台</view>
|
||||||
|
<view class="lf-nav__sub">线索、积分、风控统一看</view>
|
||||||
|
</view>
|
||||||
|
<view class="lf-pill">{{ account.roleName }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!isAdmin" class="lf-card denied-card">
|
||||||
|
<view class="denied-card__title">当前账号没有管理权限</view>
|
||||||
|
<view class="lf-caption">请使用校区管理员、顾问、老师、财务或超级管理员账号登录后访问。</view>
|
||||||
|
<u-button color="#0b4f46" shape="circle" text="切换账号" @click="confirmLogout"></u-button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="metrics-grid">
|
||||||
|
<view v-for="metric in metrics" :key="metric.label" class="metric-card lf-card">
|
||||||
|
<text>{{ metric.label }}</text>
|
||||||
|
<text>{{ metric.value }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lf-card ops-card">
|
||||||
|
<view class="lf-row">
|
||||||
|
<view class="lf-section-title">转化漏斗</view>
|
||||||
|
<text class="lf-caption">按当前账号权限展示</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="item in admin.funnel" :key="item.label" class="ops-bar">
|
||||||
|
<view class="ops-bar__head">
|
||||||
|
<text>{{ item.label }}</text>
|
||||||
|
<text>{{ item.value }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="ops-bar__track">
|
||||||
|
<view class="ops-bar__fill" :style="{ width: `${Math.max(12, item.value / admin.funnel[0].value * 100)}%` }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lf-card leads-card">
|
||||||
|
<view class="lf-row">
|
||||||
|
<view>
|
||||||
|
<view class="lf-section-title">待处理线索</view>
|
||||||
|
<view class="lf-caption">首页仅展示最新 3 条</view>
|
||||||
|
</view>
|
||||||
|
<button class="ghost-button" @click="goAdminPage('/pages/admin-leads/index')">全部线索</button>
|
||||||
|
</view>
|
||||||
|
<view v-for="lead in previewLeads" :key="lead.id" class="lead-row">
|
||||||
|
<view>
|
||||||
|
<view class="lead-row__name">{{ lead.parent }}</view>
|
||||||
|
<view class="lead-row__meta">来源 {{ lead.source }}</view>
|
||||||
|
<view class="lead-row__risk">{{ lead.channel || lead.source }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="lead-row__stage">
|
||||||
|
<u-tag :text="lead.stage" plain shape="circle" color="#0b4f46" borderColor="#b9d8d2"></u-tag>
|
||||||
|
<button :class="[lead.stage === '已报名' && 'is-disabled', nextStage(lead) === '已报名' && 'is-purchase']" @click="advance(lead)">
|
||||||
|
{{ actionLabel(lead) }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lf-card point-hub">
|
||||||
|
<view class="point-hub__main">
|
||||||
|
<view class="lf-cube-mark">分</view>
|
||||||
|
<view>
|
||||||
|
<view class="lf-section-title">积分管理</view>
|
||||||
|
<view class="lf-caption">补发、冲正、流水筛选在独立页面处理</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="point-hub__stats">
|
||||||
|
<view>
|
||||||
|
<text>{{ admin.overview.pointsCost || 0 }}</text>
|
||||||
|
<text>累计发放</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text>{{ admin.overview.attended || 0 }}</text>
|
||||||
|
<text>到课奖励</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="primary-row-button" @click="goAdminPage('/pages/admin-points/index')">进入积分管理</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lf-card course-hub">
|
||||||
|
<view class="point-hub__main">
|
||||||
|
<view class="lf-cube-mark">课</view>
|
||||||
|
<view>
|
||||||
|
<view class="lf-section-title">试听课程管理</view>
|
||||||
|
<view class="lf-caption">维护可预约试听课、席位和到课积分</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="primary-row-button" @click="goAdminPage('/pages/admin-courses/index')">进入试听管理</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<AdminTabbar v-if="isAdmin" active="home" />
|
||||||
|
|
||||||
|
<u-toast ref="toast"></u-toast>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AdminTabbar from '../../components/AdminTabbar.vue'
|
||||||
|
import { confirmLeadPurchase, getAdminDashboard, updateLeadStage } from '../../services/educationApi'
|
||||||
|
import { clearStoredAccount, getStoredAccount, isAdminAccount } from '../../services/accountSession'
|
||||||
|
|
||||||
|
const stageFlow = ['待跟进', '已注册', '已预约', '已到课', '已报名']
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { AdminTabbar },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
account: getStoredAccount() || {},
|
||||||
|
admin: { overview: { leads: 0, attended: 0, purchased: 0, pointsCost: 0 }, funnel: [], leads: [] },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isAdmin() {
|
||||||
|
return isAdminAccount(this.account)
|
||||||
|
},
|
||||||
|
metrics() {
|
||||||
|
const overview = this.admin.overview
|
||||||
|
return [
|
||||||
|
{ label: '推荐线索', value: overview.leads },
|
||||||
|
{ label: '确认到课', value: overview.attended },
|
||||||
|
{ label: '报名成交', value: overview.purchased },
|
||||||
|
{ label: '已发放积分', value: overview.pointsCost },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
previewLeads() {
|
||||||
|
return (this.admin.leads || []).slice(0, 3)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
const account = getStoredAccount()
|
||||||
|
if (!account) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.account = account
|
||||||
|
if (this.isAdmin) this.load()
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
if (this.isAdmin) await this.load()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async load() {
|
||||||
|
const res = await getAdminDashboard()
|
||||||
|
this.admin = res.data || res || this.admin
|
||||||
|
},
|
||||||
|
nextStage(lead) {
|
||||||
|
const index = stageFlow.indexOf(lead.stage)
|
||||||
|
return stageFlow[Math.min(index + 1, stageFlow.length - 1)] || '已注册'
|
||||||
|
},
|
||||||
|
actionLabel(lead) {
|
||||||
|
if (lead.stage === '已报名') return '已报名'
|
||||||
|
return this.nextStage(lead) === '已报名' ? '确认报名' : '推进'
|
||||||
|
},
|
||||||
|
async advance(lead) {
|
||||||
|
const next = this.nextStage(lead)
|
||||||
|
if (lead.stage === next) {
|
||||||
|
this.$refs.toast.show({ message: '线索已到最终阶段', type: 'default' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isPurchase = next === '已报名'
|
||||||
|
const confirmed = await this.confirmAction(
|
||||||
|
isPurchase ? '确认报名正价课' : '确认推进线索',
|
||||||
|
isPurchase
|
||||||
|
? `确认「${lead.parent}」已报名正价课?确认后会发放报名阶段推荐奖励,并更新推荐漏斗。`
|
||||||
|
: `将「${lead.parent}」从「${lead.stage}」推进到「${next}」,会影响转化统计。`,
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
const res = isPurchase ? await confirmLeadPurchase(lead.id) : await updateLeadStage(lead.id, next)
|
||||||
|
if (res.ok === false) {
|
||||||
|
this.$refs.toast.show({ message: res.message || '线索更新失败', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lead.stage = next
|
||||||
|
this.$refs.toast.show({ message: isPurchase ? '已确认报名并发放奖励' : `已推进到${next}`, type: 'success' })
|
||||||
|
},
|
||||||
|
confirmLogout() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认退出登录',
|
||||||
|
content: '退出后需要重新登录当前账号,是否继续?',
|
||||||
|
confirmText: '退出',
|
||||||
|
confirmColor: '#b54732',
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
clearStoredAccount()
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
go(url) {
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
},
|
||||||
|
goAdminPage(url) {
|
||||||
|
uni.redirectTo({ url })
|
||||||
|
},
|
||||||
|
confirmAction(title, content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmColor: '#0b4f46',
|
||||||
|
success: (res) => resolve(Boolean(res.confirm)),
|
||||||
|
fail: () => resolve(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-page {
|
||||||
|
padding-bottom: 150rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.denied-card,
|
||||||
|
.ops-card,
|
||||||
|
.leads-card {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.denied-card__title {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.denied-card .u-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
min-height: 150rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card text:first-child {
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card text:last-child {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: #0b4f46;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bar {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bar__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bar__track {
|
||||||
|
height: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #e2ebde;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bar__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #17bebb, #ff7a59);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 26rpx 0;
|
||||||
|
border-bottom: 1rpx solid #edf1e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__name {
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__meta {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__risk {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
padding: 6rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #dff8ee;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__stage button {
|
||||||
|
min-width: 92rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0b4f46;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__stage button.is-disabled {
|
||||||
|
color: #82918b;
|
||||||
|
background: #dfe8dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-row__stage button.is-purchase {
|
||||||
|
min-width: 132rpx;
|
||||||
|
background: #ff7a59;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
min-width: 136rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border: 1rpx solid #b9d8d2;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub,
|
||||||
|
.course-hub {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub__stats view {
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: #f3f8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub__stats text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub__stats text:first-child {
|
||||||
|
color: #0b4f46;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-hub__stats text:last-child {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 21rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-row-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 76rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0b4f46;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<view class="lf-page rewards-page">
|
||||||
|
<view class="lf-nav">
|
||||||
|
<view>
|
||||||
|
<view class="lf-nav__title">兑换权益</view>
|
||||||
|
<view class="lf-nav__sub">搜索可兑换课程、测评和服务权益</view>
|
||||||
|
</view>
|
||||||
|
<view class="lf-pill">{{ total }} 项</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lf-card filter-card">
|
||||||
|
<view class="search-box">
|
||||||
|
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
|
||||||
|
<input v-model="filters.keyword" placeholder="搜索权益名称、类型" confirm-type="search" @confirm="refresh" />
|
||||||
|
</view>
|
||||||
|
<view class="chip-row">
|
||||||
|
<button v-for="item in stockOptions" :key="item.value" :class="['filter-chip', filters.stockStatus === item.value && 'filter-chip--active']" @click="changeStock(item.value)">
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<button class="query-button" @click="refresh">查询</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-for="reward in list" :key="reward.id" class="lf-card reward-card">
|
||||||
|
<view class="reward-card__type">{{ reward.type }}</view>
|
||||||
|
<view class="reward-card__name">{{ reward.name }}</view>
|
||||||
|
<view class="reward-card__desc">{{ reward.description || '兑换后由校区审核发放权益券码' }}</view>
|
||||||
|
<view class="reward-card__foot">
|
||||||
|
<text>{{ reward.cost }} 积分 · 库存 {{ reward.stock }}</text>
|
||||||
|
<button :disabled="reward.stock <= 0" @click="redeem(reward)">{{ reward.stock > 0 ? '兑换' : '售罄' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-state">{{ loadText }}</view>
|
||||||
|
<u-toast ref="toast"></u-toast>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getPointRewards, redeemReward } from '../../services/educationApi'
|
||||||
|
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
finished: false,
|
||||||
|
filters: {
|
||||||
|
keyword: '',
|
||||||
|
stockStatus: 'all',
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
stockOptions: [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '有库存', value: 'in_stock' },
|
||||||
|
{ label: '已售罄', value: 'sold_out' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
loadText() {
|
||||||
|
if (this.loading) return '加载中...'
|
||||||
|
if (this.finished) return this.list.length ? '没有更多了' : '暂无权益'
|
||||||
|
return '上拉加载更多'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async onShow() {
|
||||||
|
await refreshStoredAccount()
|
||||||
|
await this.refresh()
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
await this.refresh()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
this.loadMore()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changeStock(value) {
|
||||||
|
this.filters.stockStatus = value
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
this.filters.pageNum = 1
|
||||||
|
this.finished = false
|
||||||
|
await this.load(true)
|
||||||
|
},
|
||||||
|
async loadMore() {
|
||||||
|
if (this.loading || this.finished) return
|
||||||
|
this.filters.pageNum += 1
|
||||||
|
await this.load(false)
|
||||||
|
},
|
||||||
|
async load(reset) {
|
||||||
|
const user = getStoredAccount()
|
||||||
|
if (!user) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const res = await getPointRewards(user.id, this.filters)
|
||||||
|
if (res.ok === false) {
|
||||||
|
this.$refs.toast.show({ message: res.message || '权益加载失败', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data || res
|
||||||
|
this.total = data.total || 0
|
||||||
|
this.list = reset ? (data.list || []) : this.list.concat(data.list || [])
|
||||||
|
this.finished = this.list.length >= this.total
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async redeem(reward) {
|
||||||
|
if (reward.stock <= 0) {
|
||||||
|
this.$refs.toast.show({ message: '该权益库存不足', type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const user = getStoredAccount()
|
||||||
|
if (!user) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const confirmed = await this.confirmAction('确认兑换权益', `将消耗 ${reward.cost} 积分兑换「${reward.name}」?`)
|
||||||
|
if (!confirmed) return
|
||||||
|
const res = await redeemReward({ userId: user.id, itemId: reward.id })
|
||||||
|
if (res.ok === false) {
|
||||||
|
this.$refs.toast.show({ message: res.message || '兑换失败', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$refs.toast.show({ message: '兑换申请已提交', type: 'success' })
|
||||||
|
await this.refresh()
|
||||||
|
},
|
||||||
|
confirmAction(title, content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({ title, content, confirmText: '确认', cancelText: '取消', confirmColor: '#0b4f46', success: (res) => resolve(Boolean(res.confirm)), fail: () => resolve(false) })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.filter-card,
|
||||||
|
.reward-card {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 74rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f3f8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
height: 52rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 1rpx solid #dce8dc;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip--active {
|
||||||
|
color: #0b4f46;
|
||||||
|
border-color: #9ae9dc;
|
||||||
|
background: #dff8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 68rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0b4f46;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__type {
|
||||||
|
color: #ff7a59;
|
||||||
|
font-size: 21rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__name {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 31rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__desc {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__foot text {
|
||||||
|
color: #0b4f46;
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__foot button {
|
||||||
|
min-width: 110rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0b4f46;
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-card__foot button[disabled] {
|
||||||
|
color: #7a8580;
|
||||||
|
background: #dce5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-state {
|
||||||
|
padding: 28rpx 0 80rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 23rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<view class="lf-page records-page">
|
||||||
|
<view class="lf-nav">
|
||||||
|
<view>
|
||||||
|
<view class="lf-nav__title">推荐记录</view>
|
||||||
|
<view class="lf-nav__sub">按家长、手机号、渠道和状态查询</view>
|
||||||
|
</view>
|
||||||
|
<view class="lf-pill">{{ total }} 条</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lf-card filter-card">
|
||||||
|
<view class="search-box">
|
||||||
|
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
|
||||||
|
<input v-model="filters.keyword" placeholder="搜索家长、手机号、渠道" confirm-type="search" @confirm="refresh" />
|
||||||
|
</view>
|
||||||
|
<view class="chip-row">
|
||||||
|
<button v-for="item in statusOptions" :key="item.value" :class="['filter-chip', filters.status === item.value && 'filter-chip--active']" @click="changeStatus(item.value)">
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<button class="query-button" @click="refresh">查询</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-for="record in list" :key="record.id" class="lf-card record-card">
|
||||||
|
<view>
|
||||||
|
<view class="record-card__name">{{ record.name }}</view>
|
||||||
|
<view class="record-card__meta">{{ record.phone || '-' }} · {{ record.childGrade || '-' }} · {{ record.channel || '-' }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="record-card__right">
|
||||||
|
<text>{{ record.status }}</text>
|
||||||
|
<strong>+{{ record.reward }}</strong>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-state">{{ loadText }}</view>
|
||||||
|
<u-toast ref="toast"></u-toast>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getReferralRecords } from '../../services/educationApi'
|
||||||
|
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
finished: false,
|
||||||
|
filters: {
|
||||||
|
keyword: '',
|
||||||
|
status: 'all',
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
statusOptions: [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '已注册', value: '已注册' },
|
||||||
|
{ label: '已预约', value: '已预约' },
|
||||||
|
{ label: '已到课', value: '已到课' },
|
||||||
|
{ label: '已报名', value: '已报名' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
loadText() {
|
||||||
|
if (this.loading) return '加载中...'
|
||||||
|
if (this.finished) return this.list.length ? '没有更多了' : '暂无推荐记录'
|
||||||
|
return '上拉加载更多'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async onShow() {
|
||||||
|
await refreshStoredAccount()
|
||||||
|
await this.refresh()
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
await this.refresh()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
this.loadMore()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changeStatus(status) {
|
||||||
|
this.filters.status = status
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
this.filters.pageNum = 1
|
||||||
|
this.finished = false
|
||||||
|
await this.load(true)
|
||||||
|
},
|
||||||
|
async loadMore() {
|
||||||
|
if (this.loading || this.finished) return
|
||||||
|
this.filters.pageNum += 1
|
||||||
|
await this.load(false)
|
||||||
|
},
|
||||||
|
async load(reset) {
|
||||||
|
const account = getStoredAccount()
|
||||||
|
if (!account) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const res = await getReferralRecords(account.id, this.filters)
|
||||||
|
if (res.ok === false) {
|
||||||
|
this.$refs.toast.show({ message: res.message || '推荐记录加载失败', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data?.records || res.records || {}
|
||||||
|
this.total = data.total || 0
|
||||||
|
this.list = reset ? (data.list || []) : this.list.concat(data.list || [])
|
||||||
|
this.finished = this.list.length >= this.total
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.filter-card,
|
||||||
|
.record-card {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 74rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f3f8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
height: 52rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 1rpx solid #dce8dc;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip--active {
|
||||||
|
color: #0b4f46;
|
||||||
|
border-color: #9ae9dc;
|
||||||
|
background: #dff8ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 68rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0b4f46;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__name {
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__meta {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__right text {
|
||||||
|
padding: 8rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #dff8ee;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__right strong {
|
||||||
|
color: #ff7a59;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-state {
|
||||||
|
padding: 28rpx 0 80rpx;
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 23rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { getCurrentAccount } from './educationApi'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'lfh_current_account'
|
||||||
|
|
||||||
|
export function getStoredAccount() {
|
||||||
|
try {
|
||||||
|
return uni.getStorageSync(STORAGE_KEY) || null
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredAccount(account) {
|
||||||
|
const current = getStoredAccount()
|
||||||
|
const next = {
|
||||||
|
...account,
|
||||||
|
sessionToken: account?.sessionToken || current?.sessionToken || '',
|
||||||
|
}
|
||||||
|
uni.setStorageSync(STORAGE_KEY, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredAccount() {
|
||||||
|
uni.removeStorageSync(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshStoredAccount() {
|
||||||
|
const current = getStoredAccount()
|
||||||
|
if (!current?.sessionToken) return current
|
||||||
|
const response = await getCurrentAccount()
|
||||||
|
if (response?.ok === false) return current
|
||||||
|
return setStoredAccount(response?.data || response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminAccount(account = getStoredAccount()) {
|
||||||
|
if (!account) return false
|
||||||
|
return ['super_admin', 'campus_admin', 'advisor', 'teacher', 'finance'].includes(account.role)
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { API_BASE_URL } from '../config'
|
||||||
|
|
||||||
|
function toQuery(params = {}) {
|
||||||
|
const query = Object.entries(params)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&')
|
||||||
|
return query ? `?${query}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBootstrap(accountId) {
|
||||||
|
return http.get(`/bootstrap?accountId=${encodeURIComponent(accountId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginWithPhone(phone, code, inviterCode = '') {
|
||||||
|
return http.post('/auth/phone-login', { phone, code, inviterCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendSmsCode(phone, purpose = 'login') {
|
||||||
|
return http.post('/auth/sms-code', { phone, purpose })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginWithPassword(account, password) {
|
||||||
|
return http.post('/auth/login', { account, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginWithWechat(code) {
|
||||||
|
return http.post('/auth/wechat-login', { code })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindWechatPhone(payload) {
|
||||||
|
return http.post('/auth/wechat-bind-phone', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentAccount() {
|
||||||
|
return http.get('/auth/current')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProfile(accountId, payload) {
|
||||||
|
return http.patch(`/auth/profile/${encodeURIComponent(accountId)}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePassword(accountId, payload) {
|
||||||
|
return http.patch(`/auth/password/${encodeURIComponent(accountId)}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBoundPhone(accountId, payload) {
|
||||||
|
return http.patch(`/auth/phone/${encodeURIComponent(accountId)}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadAvatar(filePath) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sessionToken = uni.getStorageSync('lfh_current_account')?.sessionToken || ''
|
||||||
|
uni.uploadFile({
|
||||||
|
url: `${API_BASE_URL}/uploads/avatar`,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
header: sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {},
|
||||||
|
success: (res) => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(res.data))
|
||||||
|
} catch (error) {
|
||||||
|
resolve({ ok: false, message: '头像上传响应格式错误' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => resolve({ ok: false, message: '头像上传失败' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReferralCenter(inviterId) {
|
||||||
|
return http.get(`/referrals${inviterId ? `?inviterId=${encodeURIComponent(inviterId)}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReferralRecords(inviterId, filters = {}) {
|
||||||
|
return http.get(`/referrals${toQuery({ ...filters, inviterId, paged: 1 })}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShareMaterial(channel, inviterId) {
|
||||||
|
return http.post('/referrals/share', { channel, inviterId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMiniProgramCode(inviterId) {
|
||||||
|
return http.post('/referrals/mini-code', { inviterId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindReferral(payload) {
|
||||||
|
return http.post('/referrals/bind', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPointCenter(userId) {
|
||||||
|
return http.get(`/points/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPointRewards(userId, filters = {}) {
|
||||||
|
return http.get(`/points/${userId}/rewards${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPointTransactions(userId, filters = {}) {
|
||||||
|
return http.get(`/points/${userId}/transactions${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPointRedemptions(userId, filters = {}) {
|
||||||
|
return http.get(`/points/${userId}/redemptions${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redeemReward(payload) {
|
||||||
|
return http.post('/redemptions', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCourses() {
|
||||||
|
return http.get('/courses')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoursesPage(filters = {}) {
|
||||||
|
return http.get(`/courses${toQuery({ ...filters, paged: 1 })}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMyAppointments(userId) {
|
||||||
|
return http.get(`/appointments?userId=${encodeURIComponent(userId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminCourses(filters = {}) {
|
||||||
|
return http.get(`/admin/courses${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminCourse(id) {
|
||||||
|
return http.get(`/admin/courses/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminAppointments(filters = {}) {
|
||||||
|
return http.get(`/admin/appointments${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attendAdminAppointment(id) {
|
||||||
|
return http.patch(`/admin/appointments/${encodeURIComponent(id)}/attend`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminCourse(payload) {
|
||||||
|
return http.post('/admin/courses', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminCourse(id, payload) {
|
||||||
|
return http.patch(`/admin/courses/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminCourse(id) {
|
||||||
|
return http.delete(`/admin/courses/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookCourse(payload) {
|
||||||
|
return http.post('/appointments', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminDashboard() {
|
||||||
|
return http.get('/admin/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminLeads(filters = {}) {
|
||||||
|
return http.get(`/admin/leads${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLeadStage(id, stage) {
|
||||||
|
return http.patch(`/admin/leads/${id}/stage`, { stage })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmLeadPurchase(id) {
|
||||||
|
return http.post(`/admin/leads/${id}/purchase`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminPointManager(filters = {}) {
|
||||||
|
return http.get(`/admin/points${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminPointAccounts(filters = {}) {
|
||||||
|
return http.get(`/admin/point-accounts${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminPointTransactions(filters = {}) {
|
||||||
|
return http.get(`/admin/point-transactions${toQuery(filters)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustPoints(payload) {
|
||||||
|
return http.post('/admin/points/adjust', payload)
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { API_BASE_URL } from '../config'
|
||||||
|
|
||||||
|
let requestSequence = 0
|
||||||
|
|
||||||
|
function getSessionToken() {
|
||||||
|
try {
|
||||||
|
return uni.getStorageSync('lfh_current_account')?.sessionToken || ''
|
||||||
|
} catch (error) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(path, options = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sessionToken = getSessionToken()
|
||||||
|
const requestPath = options.method === 'GET' || !options.method
|
||||||
|
? `${path}${path.includes('?') ? '&' : '?'}_t=${Date.now()}_${requestSequence += 1}`
|
||||||
|
: path
|
||||||
|
uni.request({
|
||||||
|
url: `${API_BASE_URL}${requestPath}`,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data || {},
|
||||||
|
enableCache: false,
|
||||||
|
header: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}),
|
||||||
|
...(options.header || {}),
|
||||||
|
},
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(res.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({ ok: false, message: res.data?.message || '请求失败' })
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
resolve({ ok: false, message: '网络异常,请确认后端服务已启动' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = {
|
||||||
|
get(path) {
|
||||||
|
return request(path, { method: 'GET' })
|
||||||
|
},
|
||||||
|
post(path, data) {
|
||||||
|
return request(path, { method: 'POST', data })
|
||||||
|
},
|
||||||
|
patch(path, data) {
|
||||||
|
return request(path, { method: 'PATCH', data })
|
||||||
|
},
|
||||||
|
delete(path) {
|
||||||
|
return request(path, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare module "vue" {
|
||||||
|
type Hooks = App.AppInstance & Page.PageInstance;
|
||||||
|
interface ComponentCustomOptions extends Hooks {}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 361 B |
|
After Width: | Height: | Size: 359 B |
|
After Width: | Height: | Size: 271 B |
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 312 B |
|
After Width: | Height: | Size: 311 B |
|
After Width: | Height: | Size: 446 B |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 304 B |
@ -0,0 +1,121 @@
|
|||||||
|
.lf-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: calc(28rpx + var(--status-bar-height)) 28rpx 56rpx;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 4%, rgba(23, 190, 187, 0.2), transparent 34%),
|
||||||
|
linear-gradient(180deg, #0b2f2a 0%, #0f3d35 260rpx, #f3f6ed 261rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-page--light {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-top: calc(28rpx + var(--status-bar-height));
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 122, 89, 0.14), transparent 32%),
|
||||||
|
linear-gradient(180deg, #f3f6ed 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 92rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-nav__title {
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-nav__sub {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-card {
|
||||||
|
border: 1rpx solid rgba(11, 79, 70, 0.1);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 18rpx 48rpx rgba(11, 47, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-card--ink {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(23, 190, 187, 0.25), transparent 38%),
|
||||||
|
#0b2f2a;
|
||||||
|
box-shadow: 0 24rpx 58rpx rgba(11, 47, 42, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-section-title {
|
||||||
|
color: #17312d;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-caption {
|
||||||
|
color: #6f7c78;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #0b4f46;
|
||||||
|
background: #dff8ee;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-ticket {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-ticket::before,
|
||||||
|
.lf-ticket::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
content: "";
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3f6ed;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-ticket::before {
|
||||||
|
left: -14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-ticket::after {
|
||||||
|
right: -14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf-cube-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 76rpx;
|
||||||
|
height: 76rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
color: #0b2f2a;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, #17bebb 0 50%, #f5c451 50% 100%);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 8rpx 8rpx 0 #ff7a59;
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
export function formatPoint(value) {
|
||||||
|
return Number(value || 0).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskPhone(phone = '') {
|
||||||
|
return phone.replace(/^(\d{3})\d{4}(\d+)/, '$1****$2')
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
uni(),
|
||||||
|
],
|
||||||
|
})
|
||||||