refactor: restructure entire project for human maintainability
Server: - Add Express framework, replace custom router/request parser - Create DAO layer (12 files) centralizing all 73 SQL queries - Rewrite 7 route files as thin Express controllers calling DAOs - Add SCF-to-Express adapter (lib/serverless.js) - Add auth middleware (middleware/auth.js) - Remove dead code from lib/auth.js Admin console: - Extract DataTable component (table + pagination) - Extract ConfirmModal component (modal + form styles) - Create listMixin for paginated list pages - Move form styles to common.css for slot compatibility - Refactor device + subscription pages as examples Miniprogram: - Split 734-line BLE monolith into 4 focused modules (protocol, connection, commands, barrel index) - Create API module (utils/api.js) with named functions - Create page utilities (utils/page.js) - Refactor index + profile pages to use API module
这个提交包含在:
+28
-27
@@ -1,34 +1,35 @@
|
||||
const Router = require('./lib/router')
|
||||
const { createContext } = require('./lib/request')
|
||||
const { ok, fail, http } = require('./lib/response')
|
||||
const express = require('express')
|
||||
const { ok, fail } = require('./lib/response')
|
||||
const { authMiddleware } = require('./middleware/auth')
|
||||
|
||||
const router = new Router()
|
||||
const app = express()
|
||||
|
||||
require('./routes/auth')(router)
|
||||
require('./routes/user')(router)
|
||||
require('./routes/device')(router)
|
||||
require('./routes/subscription')(router)
|
||||
require('./routes/treatment')(router)
|
||||
require('./routes/admin')(router)
|
||||
require('./routes/firmware')(router)
|
||||
app.use(express.json())
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform')
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204)
|
||||
next()
|
||||
})
|
||||
|
||||
async function handle(event) {
|
||||
const ctx = createContext(event || {})
|
||||
if (ctx.method === 'OPTIONS') return http(204, {})
|
||||
if (ctx.path === '/health') return http(200, ok({ status: 'ok' }))
|
||||
app.use(authMiddleware)
|
||||
|
||||
const match = router.match(ctx.method, ctx.path)
|
||||
if (!match) return http(404, fail(404, 'not_found'))
|
||||
app.get('/health', (req, res) => res.json(ok({ status: 'ok' })))
|
||||
|
||||
ctx.params = match.params
|
||||
app.use('/api/v1', require('./routes/auth'))
|
||||
app.use('/api/v1', require('./routes/user'))
|
||||
app.use('/api/v1', require('./routes/device'))
|
||||
app.use('/api/v1', require('./routes/subscription'))
|
||||
app.use('/api/v1', require('./routes/treatment'))
|
||||
app.use('/api/v1/admin', require('./routes/admin'))
|
||||
app.use('/api/v1', require('./routes/firmware'))
|
||||
|
||||
try {
|
||||
const body = await match.handler(ctx)
|
||||
return http(200, body)
|
||||
} catch (err) {
|
||||
console.error('[ERROR]', ctx.method, ctx.path, err.code || '', err.sqlMessage || err.message, err.stack)
|
||||
return http(500, fail(3001, 'server_error'))
|
||||
}
|
||||
}
|
||||
app.use((req, res) => res.status(404).json(fail(404, 'not_found')))
|
||||
|
||||
module.exports = { handle }
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error('[ERROR]', req.method, req.path, err.code || '', err.sqlMessage || err.message, err.stack)
|
||||
res.status(500).json(fail(3001, 'server_error'))
|
||||
})
|
||||
|
||||
module.exports = app
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const { query, one } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Find admin account by username (active only)
|
||||
* @param {string} username
|
||||
* @returns {Promise<Object|null>} admin row or null
|
||||
*/
|
||||
async function findByUsername(username) {
|
||||
return one(
|
||||
'SELECT * FROM admin_accounts WHERE username = :username AND status = 1',
|
||||
{ username }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find admin account by ID (active only)
|
||||
* @param {number} adminId
|
||||
* @returns {Promise<Object|null>} admin row or null
|
||||
*/
|
||||
async function findById(adminId) {
|
||||
return one(
|
||||
'SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1',
|
||||
{ admin_id: adminId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update admin password hash and clear legacy salt
|
||||
* @param {number} adminId
|
||||
* @param {string} passwordHash - bcrypt hash
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function updatePassword(adminId, passwordHash) {
|
||||
return query(
|
||||
'UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id',
|
||||
{ password_hash: passwordHash, password_salt: '', admin_id: adminId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-migrate password from legacy SHA-256 to bcrypt
|
||||
* @param {number} adminId
|
||||
* @param {string} newHash - bcrypt hash
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function migratePassword(adminId, newHash) {
|
||||
return updatePassword(adminId, newHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard aggregate counts (devices, users, treatments, active subscriptions)
|
||||
* @returns {Promise<{device_count: number, user_count: number, treatment_count: number, subscription_count: number}>}
|
||||
*/
|
||||
async function getDashboardCounts() {
|
||||
const rows = await Promise.all([
|
||||
query('SELECT COUNT(*) AS total FROM devices', {}),
|
||||
query('SELECT COUNT(*) AS total FROM users', {}),
|
||||
query('SELECT COUNT(*) AS total FROM treatment_records', {}),
|
||||
query('SELECT COUNT(*) AS total FROM subscriptions WHERE status = 1 AND expire_time > NOW()', {})
|
||||
])
|
||||
return {
|
||||
device_count: rows[0][0].total,
|
||||
user_count: rows[1][0].total,
|
||||
treatment_count: rows[2][0].total,
|
||||
subscription_count: rows[3][0].total
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription breakdown stats (monthly/yearly/trial counts, monthly revenue)
|
||||
* @returns {Promise<Object>} { monthly_count, yearly_count, trial_count, monthly_revenue }
|
||||
*/
|
||||
async function getSubscriptionStats() {
|
||||
const rows = await query(
|
||||
'SELECT ' +
|
||||
"SUM(CASE WHEN plan = 'monthly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, " +
|
||||
"SUM(CASE WHEN plan = 'yearly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, " +
|
||||
"SUM(CASE WHEN plan = 'trial' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, " +
|
||||
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
|
||||
'FROM subscriptions',
|
||||
{}
|
||||
)
|
||||
return rows[0] || {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findByUsername,
|
||||
findById,
|
||||
updatePassword,
|
||||
migratePassword,
|
||||
getDashboardCounts,
|
||||
getSubscriptionStats
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
const { query, one, transaction } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Find active binding for a user (bind_status=1)
|
||||
* Uses transaction connection when provided
|
||||
* @param {number} userId
|
||||
* @param {Object} [conn] - optional transaction connection
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findActiveByUser(userId, conn) {
|
||||
if (conn) {
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1',
|
||||
[userId]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
return one(
|
||||
'SELECT device_id FROM bindings WHERE user_id = :user_id AND bind_status = 1 LIMIT 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device exists and is not disabled (status <> 4)
|
||||
* Uses transaction connection when provided
|
||||
* @param {string} deviceId
|
||||
* @param {Object} [conn] - optional transaction connection
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findDeviceExists(deviceId, conn) {
|
||||
if (conn) {
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1',
|
||||
[deviceId]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
return one(
|
||||
'SELECT * FROM devices WHERE device_id = :device_id AND status <> 4 LIMIT 1',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pending binding request with 10-minute expiry
|
||||
* @param {number} userId
|
||||
* @param {string} deviceId
|
||||
* @param {string} bindToken
|
||||
* @param {Object} [conn] - optional transaction connection
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function createPending(userId, deviceId, bindToken, conn) {
|
||||
if (conn) {
|
||||
await conn.execute(
|
||||
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())',
|
||||
[userId, deviceId, bindToken]
|
||||
)
|
||||
return
|
||||
}
|
||||
await query(
|
||||
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (:user_id, :device_id, :bind_token, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())',
|
||||
{ user_id: userId, device_id: deviceId, bind_token: bindToken }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a pending binding by token, set bind_status=1.
|
||||
* Also ensures trial subscription if none active.
|
||||
* @param {number} userId
|
||||
* @param {string} deviceId
|
||||
* @param {string} bindToken
|
||||
* @returns {Promise<boolean>} true if confirmed, false if token invalid/expired
|
||||
*/
|
||||
async function confirmBind(userId, deviceId, bindToken) {
|
||||
return transaction(async conn => {
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT binding_id FROM bindings WHERE user_id = ? AND device_id = ? AND bind_token = ? AND bind_status = 3 AND bind_expires > NOW() LIMIT 1',
|
||||
[userId, deviceId, bindToken]
|
||||
)
|
||||
if (rows.length === 0) return false
|
||||
await conn.execute(
|
||||
'UPDATE bindings SET bind_status = 1, bind_time = NOW() WHERE binding_id = ?',
|
||||
[rows[0].binding_id]
|
||||
)
|
||||
// Ensure trial subscription
|
||||
const [subs] = await conn.execute(
|
||||
'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1',
|
||||
[userId]
|
||||
)
|
||||
if (subs.length === 0) {
|
||||
await conn.execute(
|
||||
"INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, 'trial', 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))",
|
||||
[userId]
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock bind (dev/test only): directly bind with status=1, auto-trial
|
||||
* @param {number} userId
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<{success?: boolean, duplicated?: boolean, device_id?: string, invalid?: boolean}>}
|
||||
*/
|
||||
async function mockBind(userId, deviceId) {
|
||||
return transaction(async conn => {
|
||||
const [active] = await conn.execute(
|
||||
'SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1',
|
||||
[userId]
|
||||
)
|
||||
if (active.length > 0) return { duplicated: true, device_id: active[0].device_id }
|
||||
const [devices] = await conn.execute(
|
||||
'SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1',
|
||||
[deviceId]
|
||||
)
|
||||
if (devices.length === 0) return { invalid: true }
|
||||
await conn.execute(
|
||||
'UPDATE bindings SET bind_status = 2 WHERE user_id = ? AND bind_status = 3',
|
||||
[userId]
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, 'mock', NOW(), 1, NOW())",
|
||||
[userId, deviceId]
|
||||
)
|
||||
// Ensure trial
|
||||
const [subs] = await conn.execute(
|
||||
'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1',
|
||||
[userId]
|
||||
)
|
||||
if (subs.length === 0) {
|
||||
await conn.execute(
|
||||
"INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, 'trial', 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))",
|
||||
[userId]
|
||||
)
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind device(s) for a user
|
||||
* @param {number} userId
|
||||
* @param {string|null} deviceId - specific device or null for all active bindings
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function unbindByUser(userId, deviceId) {
|
||||
return query(
|
||||
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = :user_id AND bind_status = 1 AND (:device_id IS NULL OR device_id = :device_id)',
|
||||
{ user_id: userId, device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binding history for a device with user nicknames
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function getHistoryByDevice(deviceId) {
|
||||
return query(
|
||||
'SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has an active binding to a specific device
|
||||
* @param {number} userId
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findUserDeviceBinding(userId, deviceId) {
|
||||
return one(
|
||||
'SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1',
|
||||
{ user_id: userId, device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Count active bindings for a user
|
||||
* @param {number} userId
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function countActiveByUser(userId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS total FROM bindings WHERE user_id = :user_id AND bind_status = 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findActiveByUser,
|
||||
findDeviceExists,
|
||||
createPending,
|
||||
confirmBind,
|
||||
mockBind,
|
||||
unbindByUser,
|
||||
getHistoryByDevice,
|
||||
findUserDeviceBinding,
|
||||
countActiveByUser
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
const { query, one, limitClause } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Create a device command
|
||||
* @param {string} deviceId
|
||||
* @param {number} adminId
|
||||
* @param {number} opcode
|
||||
* @param {Object} payload - will be JSON-stringified
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function create(deviceId, adminId, opcode, payload) {
|
||||
return query(
|
||||
'INSERT INTO device_commands (device_id, admin_id, opcode, payload_json, status) VALUES (:device_id, :admin_id, :opcode, :payload_json, 1)',
|
||||
{
|
||||
device_id: deviceId,
|
||||
admin_id: adminId,
|
||||
opcode,
|
||||
payload_json: JSON.stringify(payload)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List commands for a device with pagination
|
||||
* @param {string} deviceId
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function listByDevice(deviceId, { pageSize, offset }) {
|
||||
const total = await query(
|
||||
'SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
const records = await query(
|
||||
'SELECT command_id, device_id, admin_id, opcode, payload_json, status, created_at, pulled_at, finished_at, result_json FROM device_commands WHERE device_id = :device_id ORDER BY created_at DESC' + limitClause(pageSize, offset),
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commands for a device
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function countByDevice(deviceId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending commands for a device (status=1), ordered by creation time
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<Array>} commands with command_id, opcode, payload_json
|
||||
*/
|
||||
async function getPending(deviceId) {
|
||||
return query(
|
||||
'SELECT command_id, opcode, payload_json FROM device_commands WHERE device_id = :device_id AND status = 1 ORDER BY created_at ASC LIMIT 10',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark commands as pulled (status=2)
|
||||
* @param {number[]} commandIds - array of command IDs
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function markPulled(commandIds) {
|
||||
if (!commandIds || commandIds.length === 0) return []
|
||||
return query(
|
||||
'UPDATE device_commands SET status = 2, pulled_at = NOW() WHERE command_id IN (' + commandIds.map(Number).join(',') + ')',
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a command execution (set status=3 success or status=4 failure)
|
||||
* @param {number} commandId
|
||||
* @param {boolean} success
|
||||
* @param {Object} result - result payload to store as JSON
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function finish(commandId, success, result) {
|
||||
return query(
|
||||
'UPDATE device_commands SET status = :status, finished_at = NOW(), result_json = :result_json WHERE command_id = :command_id',
|
||||
{
|
||||
command_id: commandId,
|
||||
status: success ? 3 : 4,
|
||||
result_json: JSON.stringify(result)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a command by ID that belongs to a device bound to a specific user
|
||||
* @param {number} commandId
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findByIdForUser(commandId, userId) {
|
||||
return one(
|
||||
'SELECT dc.command_id FROM device_commands dc JOIN bindings b ON b.device_id = dc.device_id AND b.user_id = :user_id AND b.bind_status = 1 WHERE dc.command_id = :command_id',
|
||||
{ user_id: userId, command_id: commandId }
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
listByDevice,
|
||||
countByDevice,
|
||||
getPending,
|
||||
markPulled,
|
||||
finish,
|
||||
findByIdForUser
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const { query } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Insert a device event record
|
||||
* @param {Object} event
|
||||
* @param {string} event.device_id
|
||||
* @param {number} event.user_id
|
||||
* @param {string} [event.event_type] - defaults to 'device_error'
|
||||
* @param {string|null} [event.error_code]
|
||||
* @param {number|null} [event.temperature]
|
||||
* @param {Object} event.payload - raw payload, will be JSON-stringified
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function create(event) {
|
||||
return query(
|
||||
'INSERT INTO device_events (device_id, user_id, event_type, error_code, temperature, payload_json) VALUES (:device_id, :user_id, :event_type, :error_code, :temperature, :payload_json)',
|
||||
{
|
||||
device_id: event.device_id,
|
||||
user_id: event.user_id,
|
||||
event_type: event.event_type || 'device_error',
|
||||
error_code: event.error_code || null,
|
||||
temperature: event.temperature || null,
|
||||
payload_json: JSON.stringify(event.payload || {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
const { query, one, limitClause } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* List devices with pagination and optional keyword search
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.keyword] - search device_id or device_name
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function list({ keyword, pageSize, offset }) {
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw'
|
||||
params.kw = '%' + keyword + '%'
|
||||
}
|
||||
const total = await query('SELECT COUNT(*) AS total FROM devices d' + where, params)
|
||||
const records = await query(
|
||||
'SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1' +
|
||||
where + ' ORDER BY d.created_at DESC' + limitClause(pageSize, offset),
|
||||
params
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count devices matching optional keyword
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.keyword]
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function count({ keyword }) {
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw'
|
||||
params.kw = '%' + keyword + '%'
|
||||
}
|
||||
const rows = await query('SELECT COUNT(*) AS total FROM devices d' + where, params)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single device by ID with bound user info
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findById(deviceId) {
|
||||
return one(
|
||||
'SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1 WHERE d.device_id = :device_id',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find device by ID with binding history and recent treatments
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<{device: Object|null, binding_history: Array, recent_treatments: Array}>}
|
||||
*/
|
||||
async function findByIdWithHistory(deviceId) {
|
||||
const device = await findById(deviceId)
|
||||
if (!device) return { device: null, binding_history: [], recent_treatments: [] }
|
||||
const bindingHistory = await query(
|
||||
'SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
const recentTreatments = await query(
|
||||
'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id WHERE r.device_id = :device_id ORDER BY r.created_at DESC LIMIT 5',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
return { device, binding_history: bindingHistory, recent_treatments: recentTreatments }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a device (upsert)
|
||||
* @param {Object} device
|
||||
* @param {string} device.device_id
|
||||
* @param {string} [device.product_id]
|
||||
* @param {string} [device.device_secret]
|
||||
* @param {string} [device.device_name]
|
||||
* @param {string} [device.firmware_version]
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function create(device) {
|
||||
return query(
|
||||
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
|
||||
{
|
||||
device_id: device.device_id,
|
||||
product_id: device.product_id || 'HOX_LIGHT_MASK',
|
||||
device_secret: device.device_secret || '',
|
||||
device_name: device.device_name || '光子美容仪',
|
||||
firmware_version: device.firmware_version || '1.0.0'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-create devices by IDs, return success/failure counts
|
||||
* @param {string[]} deviceIds
|
||||
* @returns {Promise<{created: number, failed: string[]}>}
|
||||
*/
|
||||
async function createBatch(deviceIds) {
|
||||
let created = 0
|
||||
const failed = []
|
||||
for (const id of deviceIds) {
|
||||
const deviceId = String(id || '').trim()
|
||||
if (!deviceId) { failed.push(id); continue }
|
||||
try {
|
||||
await create({
|
||||
device_id: deviceId,
|
||||
product_id: 'HOX_LIGHT_MASK',
|
||||
device_secret: '',
|
||||
device_name: '光子美容仪',
|
||||
firmware_version: '1.0.0'
|
||||
})
|
||||
created++
|
||||
} catch (err) {
|
||||
failed.push(deviceId)
|
||||
}
|
||||
}
|
||||
return { created, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-unbind a device (set bind_status=2)
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function unbind(deviceId) {
|
||||
return query(
|
||||
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE device_id = :device_id AND bind_status = 1',
|
||||
{ device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's bound devices with device details
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function listByUser(userId) {
|
||||
return query(
|
||||
'SELECT d.device_id, d.device_name, d.status, d.battery, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 ORDER BY b.bind_time DESC',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single bound device for a user
|
||||
* @param {number} userId
|
||||
* @param {string} deviceId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findBoundDevice(userId, deviceId) {
|
||||
return one(
|
||||
'SELECT d.device_id, d.device_name, d.status, d.battery, d.temperature, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 AND b.device_id = :device_id',
|
||||
{ user_id: userId, device_id: deviceId }
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
count,
|
||||
findById,
|
||||
findByIdWithHistory,
|
||||
create,
|
||||
createBatch,
|
||||
unbind,
|
||||
listByUser,
|
||||
findBoundDevice
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const { query, one } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* List all firmware files ordered by creation date (newest first)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function list() {
|
||||
return query(
|
||||
'SELECT firmware_id, version, device_type, cos_key, size_bytes, sha256, status, created_at FROM firmware_files ORDER BY created_at DESC',
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a firmware record
|
||||
* @param {Object} firmware
|
||||
* @param {string} firmware.version
|
||||
* @param {string} [firmware.device_type]
|
||||
* @param {string} firmware.cos_key
|
||||
* @param {number} [firmware.size_bytes]
|
||||
* @param {string} [firmware.sha256]
|
||||
* @param {number} [firmware.status] - 0=disabled, 1=enabled (default 1)
|
||||
* @returns {Promise<Object>} query result with insertId
|
||||
*/
|
||||
async function create(firmware) {
|
||||
return query(
|
||||
'INSERT INTO firmware_files (version, device_type, cos_key, size_bytes, sha256, status) VALUES (:version, :device_type, :cos_key, :size_bytes, :sha256, :status)',
|
||||
{
|
||||
version: firmware.version,
|
||||
device_type: firmware.device_type || '',
|
||||
cos_key: firmware.cos_key,
|
||||
size_bytes: Number(firmware.size_bytes || 0),
|
||||
sha256: firmware.sha256 || '',
|
||||
status: firmware.status === 0 ? 0 : 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update firmware status (enable/disable)
|
||||
* @param {number} firmwareId
|
||||
* @param {number} status - 0 or 1
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function updateStatus(firmwareId, status) {
|
||||
return query(
|
||||
'UPDATE firmware_files SET status = :status WHERE firmware_id = :firmware_id',
|
||||
{ status, firmware_id: firmwareId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest enabled firmware
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findLatest() {
|
||||
return one(
|
||||
'SELECT * FROM firmware_files WHERE status = 1 ORDER BY created_at DESC LIMIT 1',
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
create,
|
||||
updateStatus,
|
||||
findLatest
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
adminDao: require('./admin.dao'),
|
||||
deviceDao: require('./device.dao'),
|
||||
bindingDao: require('./binding.dao'),
|
||||
subscriptionDao: require('./subscription.dao'),
|
||||
treatmentDao: require('./treatment.dao'),
|
||||
userDao: require('./user.dao'),
|
||||
logDao: require('./log.dao'),
|
||||
commandDao: require('./command.dao'),
|
||||
settingsDao: require('./settings.dao'),
|
||||
firmwareDao: require('./firmware.dao'),
|
||||
deviceEventDao: require('./device-event.dao')
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
const { query, limitClause } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Write an operation log entry
|
||||
* @param {Object} options
|
||||
* @param {number|null} [options.user_id]
|
||||
* @param {number|null} [options.admin_id]
|
||||
* @param {string} options.action
|
||||
* @param {string} [options.detail]
|
||||
* @param {string} [options.ip]
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function write(options) {
|
||||
return query(
|
||||
'INSERT INTO operation_logs (user_id, admin_id, action, detail, ip) VALUES (:user_id, :admin_id, :action, :detail, :ip)',
|
||||
{
|
||||
user_id: options.user_id || null,
|
||||
admin_id: options.admin_id || null,
|
||||
action: options.action,
|
||||
detail: options.detail || '',
|
||||
ip: options.ip || ''
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin paginated list of operation logs with optional filters
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.type] - filter by action (LIKE match)
|
||||
* @param {string} [opts.deviceId] - filter by detail containing device ID
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function list({ type, deviceId, pageSize, offset }) {
|
||||
const conditions = []
|
||||
const params = {}
|
||||
if (type) {
|
||||
conditions.push('action LIKE :type')
|
||||
params.type = '%' + type + '%'
|
||||
}
|
||||
if (deviceId) {
|
||||
conditions.push('detail LIKE :device_id')
|
||||
params.device_id = '%' + deviceId + '%'
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const total = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params)
|
||||
const records = await query(
|
||||
'SELECT * FROM operation_logs' + where + ' ORDER BY created_at DESC' + limitClause(pageSize, offset),
|
||||
params
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count operation logs with optional filters
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.type]
|
||||
* @param {string} [opts.deviceId]
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function count({ type, deviceId }) {
|
||||
const conditions = []
|
||||
const params = {}
|
||||
if (type) {
|
||||
conditions.push('action LIKE :type')
|
||||
params.type = '%' + type + '%'
|
||||
}
|
||||
if (deviceId) {
|
||||
conditions.push('detail LIKE :device_id')
|
||||
params.device_id = '%' + deviceId + '%'
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const rows = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
write,
|
||||
list,
|
||||
count
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const { query } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Get all system settings, parsing JSON values where possible
|
||||
* @returns {Promise<Object>} key-value map of settings
|
||||
*/
|
||||
async function getAll() {
|
||||
const rows = await query('SELECT setting_key, setting_value FROM system_settings', {})
|
||||
const settings = {}
|
||||
rows.forEach(row => {
|
||||
if (typeof row.setting_value === 'string') {
|
||||
try {
|
||||
settings[row.setting_key] = JSON.parse(row.setting_value)
|
||||
} catch (_) {
|
||||
settings[row.setting_key] = row.setting_value
|
||||
}
|
||||
} else {
|
||||
settings[row.setting_key] = row.setting_value
|
||||
}
|
||||
})
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a system setting (REPLACE INTO)
|
||||
* @param {string} key - setting_key
|
||||
* @param {*} value - will be JSON-stringified
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function update(key, value) {
|
||||
return query(
|
||||
'REPLACE INTO system_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value)',
|
||||
{ setting_key: key, setting_value: JSON.stringify(value) }
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAll,
|
||||
update
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
const { query, one, transaction, limitClause } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Find active subscription for a user with remaining days
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>} subscription row with remaining_days, or null
|
||||
*/
|
||||
async function findActive(userId) {
|
||||
return one(
|
||||
'SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active subscription summary (plan + remaining_days) for a user
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findActiveSummary(userId) {
|
||||
return one(
|
||||
'SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has ever had a trial subscription
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>} subscription row or null
|
||||
*/
|
||||
async function findTrial(userId) {
|
||||
return one(
|
||||
"SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = 'trial' LIMIT 1",
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has any active subscription
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findAnyActive(userId) {
|
||||
return one(
|
||||
'SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trial subscription (7 days, amount=0)
|
||||
* @param {number} userId
|
||||
* @param {string} [orderId] - optional order ID
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function createTrial(userId, orderId) {
|
||||
return query(
|
||||
"INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, 'trial', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))",
|
||||
{ user_id: userId, order_id: orderId || 'TRIAL' + Date.now() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase / activate a subscription: expire old active subs, insert new one
|
||||
* @param {number} userId
|
||||
* @param {string} plan - 'monthly' | 'yearly' | 'trial'
|
||||
* @param {number} amount
|
||||
* @param {string} orderId
|
||||
* @param {number} days
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function purchase(userId, plan, amount, orderId, days) {
|
||||
return transaction(async conn => {
|
||||
await conn.execute(
|
||||
'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1',
|
||||
[userId]
|
||||
)
|
||||
await conn.execute(
|
||||
'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))',
|
||||
[userId, plan, amount, orderId, days]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-create a subscription (expire old, insert new) without transaction
|
||||
* Used by admin subscription creation endpoint
|
||||
* @param {number} userId
|
||||
* @param {string} plan
|
||||
* @param {number} amount
|
||||
* @param {string} orderId
|
||||
* @param {number} days
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function adminCreate(userId, plan, amount, orderId, days) {
|
||||
await query(
|
||||
'UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
await query(
|
||||
'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))',
|
||||
{ user_id: userId, plan, amount, order_id: orderId, days }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription (set status=3)
|
||||
* @param {number} subscriptionId
|
||||
* @returns {Promise<Array>} query result (check affectedRows)
|
||||
*/
|
||||
async function cancel(subscriptionId) {
|
||||
return query(
|
||||
'UPDATE subscriptions SET status = 3 WHERE subscription_id = :subscription_id AND status = 1',
|
||||
{ subscription_id: subscriptionId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin paginated subscription list with user nickname and optional tab filter
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.tab] - 'all' | 'monthly' | 'yearly' | 'trial' | 'expired'
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function list({ tab, pageSize, offset }) {
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (tab && tab !== 'all') {
|
||||
if (tab === 'expired') {
|
||||
where = ' WHERE s.status = 2'
|
||||
} else {
|
||||
where = ' WHERE s.plan = :plan'
|
||||
params.plan = tab
|
||||
}
|
||||
}
|
||||
const total = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params)
|
||||
const records = await query(
|
||||
'SELECT s.*, u.nickname FROM subscriptions s LEFT JOIN users u ON u.user_id = s.user_id' +
|
||||
where + ' ORDER BY s.created_at DESC' + limitClause(pageSize, offset),
|
||||
params
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count subscriptions with optional tab filter
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.tab]
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function count({ tab }) {
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (tab && tab !== 'all') {
|
||||
if (tab === 'expired') {
|
||||
where = ' WHERE s.status = 2'
|
||||
} else {
|
||||
where = ' WHERE s.plan = :plan'
|
||||
params.plan = tab
|
||||
}
|
||||
}
|
||||
const rows = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription stats: plan counts + monthly revenue
|
||||
* @returns {Promise<Object>} { monthly_count, yearly_count, trial_count, monthly_revenue }
|
||||
*/
|
||||
async function getStats() {
|
||||
const rows = await query(
|
||||
'SELECT ' +
|
||||
"SUM(CASE WHEN plan = 'monthly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, " +
|
||||
"SUM(CASE WHEN plan = 'yearly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, " +
|
||||
"SUM(CASE WHEN plan = 'trial' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, " +
|
||||
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
|
||||
'FROM subscriptions',
|
||||
{}
|
||||
)
|
||||
return rows[0] || {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findActive,
|
||||
findActiveSummary,
|
||||
findTrial,
|
||||
findAnyActive,
|
||||
createTrial,
|
||||
purchase,
|
||||
adminCreate,
|
||||
cancel,
|
||||
list,
|
||||
count,
|
||||
getStats
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
const { query, one, limitClause } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* List treatment records for a user with pagination
|
||||
* @param {number} userId
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function listByUser(userId, { pageSize, offset }) {
|
||||
const total = await query(
|
||||
'SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id',
|
||||
{ user_id: userId }
|
||||
)
|
||||
const records = await query(
|
||||
'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC' + limitClause(pageSize, offset),
|
||||
{ user_id: userId }
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count treatment records for a user
|
||||
* @param {number} userId
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function countByUser(userId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id',
|
||||
{ user_id: userId }
|
||||
)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a treatment record by session ID (optionally scoped to user)
|
||||
* @param {string} sessionId
|
||||
* @param {number} [userId] - if provided, restrict to this user
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findBySession(sessionId, userId) {
|
||||
if (userId !== undefined) {
|
||||
return one(
|
||||
'SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id',
|
||||
{ session_id: sessionId, user_id: userId }
|
||||
)
|
||||
}
|
||||
return one(
|
||||
'SELECT * FROM treatment_records WHERE session_id = :session_id',
|
||||
{ session_id: sessionId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a treatment record (upsert by session_id)
|
||||
* @param {Object} record
|
||||
* @param {string} record.session_id
|
||||
* @param {string} record.device_id
|
||||
* @param {number} record.user_id
|
||||
* @param {string|null} record.start_time - MySQL datetime string
|
||||
* @param {string|null} record.end_time
|
||||
* @param {string} record.regions - comma-separated
|
||||
* @param {number} record.total_duration_ms
|
||||
* @param {number} record.mode
|
||||
* @param {number} record.avg_pd
|
||||
* @param {number|null} record.battery
|
||||
* @param {number|null} record.temperature
|
||||
* @param {number|null} record.wavelength
|
||||
* @param {number|null} record.brightness
|
||||
* @param {string} record.pd_json - JSON string
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function create(record) {
|
||||
return query(
|
||||
`INSERT INTO treatment_records
|
||||
(session_id, device_id, user_id, start_time, end_time, regions, total_duration_ms, mode, avg_pd, battery, temperature, wavelength, brightness, pd_json)
|
||||
VALUES (:session_id, :device_id, :user_id, :start_time, :end_time, :regions, :total_duration_ms, :mode, :avg_pd, :battery, :temperature, :wavelength, :brightness, :pd_json)
|
||||
ON DUPLICATE KEY UPDATE end_time = VALUES(end_time), total_duration_ms = VALUES(total_duration_ms), avg_pd = VALUES(avg_pd), battery = VALUES(battery), temperature = VALUES(temperature), pd_json = VALUES(pd_json)`,
|
||||
record
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update device battery, temperature, and last_online_at
|
||||
* @param {string} deviceId
|
||||
* @param {number|null} battery
|
||||
* @param {number|null} temperature
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function updateDevice(deviceId, battery, temperature) {
|
||||
return query(
|
||||
'UPDATE devices SET battery = COALESCE(:battery, battery), temperature = COALESCE(:temperature, temperature), last_online_at = NOW() WHERE device_id = :device_id',
|
||||
{ device_id: deviceId, battery, temperature }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin paginated list of treatment records with user nickname
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.keyword] - search by user nickname
|
||||
* @param {string} [opts.dateFrom] - start date filter (inclusive)
|
||||
* @param {string} [opts.dateTo] - end date filter (inclusive)
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function listAdmin({ keyword, dateFrom, dateTo, pageSize, offset }) {
|
||||
const conditions = []
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
conditions.push('u.nickname LIKE :kw')
|
||||
params.kw = '%' + keyword + '%'
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push('r.created_at >= :date_from')
|
||||
params.date_from = dateFrom
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push('r.created_at <= :date_to')
|
||||
params.date_to = dateTo
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const total = await query(
|
||||
'SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where,
|
||||
params
|
||||
)
|
||||
const records = await query(
|
||||
'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' +
|
||||
where + ' ORDER BY r.created_at DESC' + limitClause(pageSize, offset),
|
||||
params
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count admin treatment records with filters
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.keyword]
|
||||
* @param {string} [opts.dateFrom]
|
||||
* @param {string} [opts.dateTo]
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function countAdmin({ keyword, dateFrom, dateTo }) {
|
||||
const conditions = []
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
conditions.push('u.nickname LIKE :kw')
|
||||
params.kw = '%' + keyword + '%'
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push('r.created_at >= :date_from')
|
||||
params.date_from = dateFrom
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push('r.created_at <= :date_to')
|
||||
params.date_to = dateTo
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where,
|
||||
params
|
||||
)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
/**
|
||||
* Get treatment stats for a user (count + total duration)
|
||||
* @param {number} userId
|
||||
* @returns {Promise<{treatment_count: number, total_duration: number}>}
|
||||
*/
|
||||
async function getStatsByUser(userId) {
|
||||
const row = await one(
|
||||
'SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id',
|
||||
{ user_id: userId }
|
||||
)
|
||||
return row || { treatment_count: 0, total_duration: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent treatments for a user (limit 5)
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function recentByUser(userId) {
|
||||
return query(
|
||||
'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listByUser,
|
||||
countByUser,
|
||||
findBySession,
|
||||
create,
|
||||
updateDevice,
|
||||
listAdmin,
|
||||
countAdmin,
|
||||
getStatsByUser,
|
||||
recentByUser
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
const { query, one, limitClause } = require('../lib/db')
|
||||
|
||||
/**
|
||||
* Find user by WeChat openid
|
||||
* @param {string} openid
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findByOpenid(openid) {
|
||||
return one(
|
||||
'SELECT * FROM users WHERE openid = :openid',
|
||||
{ openid }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user from WeChat login
|
||||
* @param {string} openid
|
||||
* @returns {Promise<Object>} query result with insertId
|
||||
*/
|
||||
async function create(openid) {
|
||||
return query(
|
||||
'INSERT INTO users (openid, nickname, avatar, status) VALUES (:openid, :nickname, :avatar, 1)',
|
||||
{ openid, nickname: '', avatar: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by user_id
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findById(userId) {
|
||||
return one(
|
||||
'SELECT * FROM users WHERE user_id = :user_id',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active user by user_id (status=1)
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function findActiveById(userId) {
|
||||
return one(
|
||||
'SELECT * FROM users WHERE user_id = :user_id AND status = 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile fields (nickname, avatar, gender)
|
||||
* @param {number} userId
|
||||
* @param {Object} fields
|
||||
* @param {string|null} [fields.nickname]
|
||||
* @param {string|null} [fields.avatar]
|
||||
* @param {number|null} [fields.gender]
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function updateProfile(userId, fields) {
|
||||
return query(
|
||||
'UPDATE users SET nickname = COALESCE(:nickname, nickname), avatar = COALESCE(:avatar, avatar), gender = COALESCE(:gender, gender) WHERE user_id = :user_id',
|
||||
{
|
||||
user_id: userId,
|
||||
nickname: fields.nickname || null,
|
||||
avatar: fields.avatar || null,
|
||||
gender: fields.gender === undefined ? null : fields.gender
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user phone number
|
||||
* @param {number} userId
|
||||
* @param {string} phone
|
||||
* @returns {Promise<Array>} query result
|
||||
*/
|
||||
async function updatePhone(userId, phone) {
|
||||
return query(
|
||||
'UPDATE users SET phone = :phone WHERE user_id = :user_id',
|
||||
{ user_id: userId, phone }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin paginated user list with device/treatment/subscription subquery stats
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.keyword] - search nickname, phone, or exact user_id
|
||||
* @param {number} opts.pageSize
|
||||
* @param {number} opts.offset
|
||||
* @returns {Promise<{records: Array, total: number}>}
|
||||
*/
|
||||
async function listAdmin({ keyword, pageSize, offset }) {
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword'
|
||||
params.kw = '%' + keyword + '%'
|
||||
params.keyword = keyword
|
||||
}
|
||||
const total = await query('SELECT COUNT(*) AS total FROM users u' + where, params)
|
||||
const records = await query(
|
||||
'SELECT u.*,' +
|
||||
' (SELECT COUNT(*) FROM bindings WHERE user_id = u.user_id AND bind_status = 1) AS device_count,' +
|
||||
' (SELECT COUNT(*) FROM treatment_records WHERE user_id = u.user_id) AS treatment_count,' +
|
||||
' COALESCE((SELECT status FROM subscriptions WHERE user_id = u.user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1), 0) AS subscription_status' +
|
||||
' FROM users u' + where + ' ORDER BY u.created_at DESC' + limitClause(pageSize, offset),
|
||||
params
|
||||
)
|
||||
return { records, total: total[0].total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count admin users with optional keyword
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.keyword]
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function countAdmin({ keyword }) {
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword'
|
||||
params.kw = '%' + keyword + '%'
|
||||
params.keyword = keyword
|
||||
}
|
||||
const rows = await query('SELECT COUNT(*) AS total FROM users u' + where, params)
|
||||
return rows[0].total
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin detail view: user + bound devices, recent treatments, subscription, stats
|
||||
* @param {number} userId
|
||||
* @returns {Promise<Object|null>} enriched user object or null
|
||||
*/
|
||||
async function findByIdAdmin(userId) {
|
||||
const user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: userId })
|
||||
if (!user) return null
|
||||
|
||||
const devices = await query(
|
||||
'SELECT d.device_id, d.device_name FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
const treatments = await query(
|
||||
'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5',
|
||||
{ user_id: userId }
|
||||
)
|
||||
const subscription = await one(
|
||||
'SELECT plan, status, start_time, expire_time FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1',
|
||||
{ user_id: userId }
|
||||
)
|
||||
const stats = await one(
|
||||
'SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id',
|
||||
{ user_id: userId }
|
||||
)
|
||||
|
||||
return Object.assign({}, user, {
|
||||
devices,
|
||||
recent_treatments: treatments,
|
||||
subscription_status: subscription ? subscription.status : 0,
|
||||
subscription_type: subscription ? subscription.plan : null,
|
||||
subscription_expire: subscription ? subscription.expire_time : null,
|
||||
treatment_count: stats ? stats.treatment_count : 0,
|
||||
total_duration: stats ? stats.total_duration : 0
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findByOpenid,
|
||||
create,
|
||||
findById,
|
||||
findActiveById,
|
||||
updateProfile,
|
||||
updatePhone,
|
||||
listAdmin,
|
||||
countAdmin,
|
||||
findByIdAdmin
|
||||
}
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
const { handle } = require('./app')
|
||||
const serverless = require('./lib/serverless')
|
||||
const app = require('./app')
|
||||
|
||||
const handler = serverless(app)
|
||||
|
||||
exports.main_handler = async (event, context) => {
|
||||
return handle(event, context)
|
||||
return handler(event, context)
|
||||
}
|
||||
|
||||
exports.main = exports.main_handler
|
||||
|
||||
+1
-26
@@ -2,7 +2,6 @@ const crypto = require('crypto')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const bcrypt = require('bcryptjs')
|
||||
const config = require('../config')
|
||||
const { one } = require('./db')
|
||||
|
||||
function hashPasswordLegacy(password, salt) {
|
||||
return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex')
|
||||
@@ -34,28 +33,4 @@ function readBearer(headers) {
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
async function requireUser(ctx) {
|
||||
const token = readBearer(ctx.headers)
|
||||
if (!token) return null
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.secret)
|
||||
if (payload.type !== 'user') return null
|
||||
return await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAdmin(ctx) {
|
||||
const token = readBearer(ctx.headers)
|
||||
if (!token) return null
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.adminSecret)
|
||||
if (payload.type !== 'admin') return null
|
||||
return await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: payload.admin_id })
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer, requireUser, requireAdmin }
|
||||
module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer }
|
||||
|
||||
+2
-16
@@ -1,16 +1,2 @@
|
||||
const { query } = require('./db')
|
||||
|
||||
async function writeLog(options) {
|
||||
await query(
|
||||
'INSERT INTO operation_logs (user_id, admin_id, action, detail, ip) VALUES (:user_id, :admin_id, :action, :detail, :ip)',
|
||||
{
|
||||
user_id: options.user_id || null,
|
||||
admin_id: options.admin_id || null,
|
||||
action: options.action,
|
||||
detail: options.detail || '',
|
||||
ip: options.ip || ''
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { writeLog }
|
||||
const logDao = require('../dao/log.dao')
|
||||
module.exports = { writeLog: logDao.write }
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
function normalizeHeaders(headers) {
|
||||
const result = {}
|
||||
Object.keys(headers || {}).forEach(key => {
|
||||
result[key] = headers[key]
|
||||
result[key.toLowerCase()] = headers[key]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function parseBody(event) {
|
||||
if (!event.body) return {}
|
||||
if (typeof event.body === 'object') return event.body
|
||||
const raw = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body
|
||||
if (!raw) return {}
|
||||
try { return JSON.parse(raw) } catch (err) { return {} }
|
||||
}
|
||||
|
||||
function parseQuery(event) {
|
||||
if (event.queryStringParameters) return event.queryStringParameters || {}
|
||||
if (event.query) return event.query || {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function getPath(event) {
|
||||
return event.path || event.Path || event.requestContext && event.requestContext.path || '/'
|
||||
}
|
||||
|
||||
function getMethod(event) {
|
||||
return String(event.httpMethod || event.method || event.requestContext && event.requestContext.httpMethod || 'GET').toUpperCase()
|
||||
}
|
||||
|
||||
function createContext(event) {
|
||||
return {
|
||||
event,
|
||||
method: getMethod(event),
|
||||
path: getPath(event),
|
||||
headers: normalizeHeaders(event.headers),
|
||||
query: parseQuery(event),
|
||||
body: parseBody(event),
|
||||
params: {},
|
||||
ip: event.requestContext && event.requestContext.sourceIp || (event.headers && (event.headers['x-forwarded-for'] || event.headers['X-Forwarded-For'] || '').split(',')[0].trim()) || ''
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createContext }
|
||||
+1
-15
@@ -6,18 +6,4 @@ function fail(code, message, data) {
|
||||
return { code, message, data: data || {} }
|
||||
}
|
||||
|
||||
function http(statusCode, body, headers) {
|
||||
return {
|
||||
isBase64Encoded: false,
|
||||
statusCode,
|
||||
headers: Object.assign({
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS'
|
||||
}, headers || {}),
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ok, fail, http }
|
||||
module.exports = { ok, fail }
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
class Router {
|
||||
constructor() {
|
||||
this.routes = []
|
||||
}
|
||||
|
||||
add(method, pattern, handler) {
|
||||
const keys = []
|
||||
const regex = new RegExp('^' + pattern.replace(/\/:(\w+)/g, function (_, key) {
|
||||
keys.push(key)
|
||||
return '/([^/]+)'
|
||||
}) + '$')
|
||||
this.routes.push({ method, regex, keys, handler })
|
||||
}
|
||||
|
||||
get(pattern, handler) { this.add('GET', pattern, handler) }
|
||||
post(pattern, handler) { this.add('POST', pattern, handler) }
|
||||
put(pattern, handler) { this.add('PUT', pattern, handler) }
|
||||
delete(pattern, handler) { this.add('DELETE', pattern, handler) }
|
||||
|
||||
match(method, path) {
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue
|
||||
const match = path.match(route.regex)
|
||||
if (!match) continue
|
||||
const params = {}
|
||||
route.keys.forEach((key, index) => { params[key] = decodeURIComponent(match[index + 1]) })
|
||||
return { handler: route.handler, params }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Router
|
||||
@@ -0,0 +1,63 @@
|
||||
const http = require('http')
|
||||
|
||||
module.exports = function serverless(app) {
|
||||
return async function handler(event) {
|
||||
const method = String(event.httpMethod || event.method || 'GET').toUpperCase()
|
||||
const path = event.path || '/'
|
||||
const headers = event.headers || {}
|
||||
const qs = event.queryStringParameters || {}
|
||||
const qsStr = Object.keys(qs).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(qs[k])).join('&')
|
||||
const url = path + (qsStr ? '?' + qsStr : '')
|
||||
|
||||
let rawBody = event.body || ''
|
||||
if (event.isBase64Encoded && rawBody) rawBody = Buffer.from(rawBody, 'base64').toString('utf8')
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = new http.IncomingMessage()
|
||||
req.method = method
|
||||
req.url = url
|
||||
req.headers = {}
|
||||
Object.keys(headers).forEach(k => { req.headers[k.toLowerCase()] = headers[k] })
|
||||
if (event.requestContext && event.requestContext.sourceIp) {
|
||||
req.headers['x-forwarded-for'] = req.headers['x-forwarded-for'] || event.requestContext.sourceIp
|
||||
}
|
||||
|
||||
const res = new http.ServerResponse(req)
|
||||
let body = ''
|
||||
const resHeaders = {}
|
||||
|
||||
res.writeHead = function (statusCode, reasonOrHeaders, maybeHeaders) {
|
||||
res.statusCode = statusCode
|
||||
const h = maybeHeaders || (typeof reasonOrHeaders === 'object' ? reasonOrHeaders : {})
|
||||
Object.assign(resHeaders, h)
|
||||
}
|
||||
|
||||
const originalSetHeader = res.setHeader.bind(res)
|
||||
res.setHeader = function (name, value) {
|
||||
resHeaders[name.toLowerCase()] = value
|
||||
originalSetHeader(name, value)
|
||||
}
|
||||
|
||||
res.end = function (chunk) {
|
||||
if (chunk) body += chunk
|
||||
resolve({
|
||||
isBase64Encoded: false,
|
||||
statusCode: res.statusCode || 200,
|
||||
headers: Object.assign({
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
}, resHeaders),
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
res.write = function (chunk) { body += chunk }
|
||||
|
||||
if (rawBody) {
|
||||
req.push(rawBody)
|
||||
}
|
||||
req.push(null)
|
||||
|
||||
app(req, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const config = require('../config')
|
||||
const { one } = require('../lib/db')
|
||||
|
||||
function readBearer(headers) {
|
||||
const auth = headers.authorization || ''
|
||||
const match = auth.match(/^Bearer\s+(.+)$/i)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
req.ip = req.headers['x-forwarded-for']
|
||||
? req.headers['x-forwarded-for'].split(',')[0].trim()
|
||||
: req.ip || ''
|
||||
next()
|
||||
}
|
||||
|
||||
async function requireUser(req, res, next) {
|
||||
const token = readBearer(req.headers)
|
||||
if (!token) return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.secret)
|
||||
if (payload.type !== 'user') return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
|
||||
req.user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
|
||||
if (!req.user) return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
|
||||
next()
|
||||
} catch (err) {
|
||||
return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAdmin(req, res, next) {
|
||||
const token = readBearer(req.headers)
|
||||
if (!token) return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.adminSecret)
|
||||
if (payload.type !== 'admin') return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
|
||||
req.admin = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: payload.admin_id })
|
||||
if (!req.admin) return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
|
||||
next()
|
||||
} catch (err) {
|
||||
return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, requireUser, requireAdmin, readBearer }
|
||||
+207
-336
@@ -1,364 +1,235 @@
|
||||
const { one, query, limitClause } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin, requireAdmin } = require('../lib/auth')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin } = require('../lib/auth')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const adminDao = require('../dao/admin.dao')
|
||||
const deviceDao = require('../dao/device.dao')
|
||||
const bindingDao = require('../dao/binding.dao')
|
||||
const commandDao = require('../dao/command.dao')
|
||||
const userDao = require('../dao/user.dao')
|
||||
const subscriptionDao = require('../dao/subscription.dao')
|
||||
const treatmentDao = require('../dao/treatment.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
const settingsDao = require('../dao/settings.dao')
|
||||
|
||||
function pageParams(ctx) {
|
||||
const page = Math.max(1, parseInt(ctx.query.page, 10) || 1)
|
||||
const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size, 10) || 20), 100)
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
function pageParams(query) {
|
||||
const page = Math.max(1, parseInt(query.page, 10) || 1)
|
||||
const pageSize = Math.min(Math.max(1, parseInt(query.page_size, 10) || 20), 100)
|
||||
return { page, pageSize, offset: (page - 1) * pageSize }
|
||||
}
|
||||
|
||||
function register(router) {
|
||||
router.post('/api/v1/admin/login', async ctx => {
|
||||
const username = ctx.body.username || ''
|
||||
const password = ctx.body.password || ''
|
||||
const admin = await one('SELECT * FROM admin_accounts WHERE username = :username AND status = 1', { username })
|
||||
if (!admin) return fail(1001, '用户名或密码错误')
|
||||
let matched = verifyPassword(password, admin.password_hash)
|
||||
if (!matched) {
|
||||
// Try legacy SHA-256 verification for migration
|
||||
if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) {
|
||||
// Auto-migrate to bcrypt
|
||||
const newHash = hashPassword(password)
|
||||
await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id })
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if (!matched) return fail(1001, '用户名或密码错误')
|
||||
const token = signAdmin(admin)
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: ctx.ip })
|
||||
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
|
||||
})
|
||||
// --- Auth ---
|
||||
|
||||
router.post('/api/v1/admin/password', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const oldPassword = ctx.body.old_password || ''
|
||||
const newPassword = ctx.body.new_password || ''
|
||||
if (newPassword.length < 6) return fail(2001, 'password too short')
|
||||
const current = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: admin.admin_id })
|
||||
if (!current) return fail(1002, '未授权,请重新登录')
|
||||
let matched = verifyPassword(oldPassword, current.password_hash)
|
||||
if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) {
|
||||
router.post('/login', wrap(async (req, res) => {
|
||||
const username = req.body.username || ''
|
||||
const password = req.body.password || ''
|
||||
const admin = await adminDao.findByUsername(username)
|
||||
if (!admin) return res.json(fail(1001, '用户名或密码错误'))
|
||||
let matched = verifyPassword(password, admin.password_hash)
|
||||
if (!matched) {
|
||||
if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) {
|
||||
const newHash = hashPassword(password)
|
||||
await adminDao.updatePassword(admin.admin_id, newHash)
|
||||
matched = true
|
||||
}
|
||||
if (!matched) return fail(1001, '原密码错误')
|
||||
const newHash = hashPassword(newPassword)
|
||||
await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id })
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: ctx.ip })
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
}
|
||||
if (!matched) return res.json(fail(1001, '用户名或密码错误'))
|
||||
const token = signAdmin(admin)
|
||||
await logDao.write({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: req.ip })
|
||||
res.json(ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/dashboard', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const rows = await Promise.all([
|
||||
query('SELECT COUNT(*) AS total FROM devices', {}),
|
||||
query('SELECT COUNT(*) AS total FROM users', {}),
|
||||
query('SELECT COUNT(*) AS total FROM treatment_records', {}),
|
||||
query('SELECT COUNT(*) AS total FROM subscriptions WHERE status = 1 AND expire_time > NOW()', {})
|
||||
])
|
||||
const subStats = await query('SELECT ' +
|
||||
'SUM(CASE WHEN plan = \'monthly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, ' +
|
||||
'SUM(CASE WHEN plan = \'yearly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, ' +
|
||||
'SUM(CASE WHEN plan = \'trial\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, ' +
|
||||
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
|
||||
'FROM subscriptions', {})
|
||||
return ok({
|
||||
device_count: rows[0][0].total,
|
||||
user_count: rows[1][0].total,
|
||||
treatment_count: rows[2][0].total,
|
||||
subscription_count: rows[3][0].total,
|
||||
sub_stats: subStats[0] || {}
|
||||
})
|
||||
})
|
||||
router.post('/password', requireAdmin, wrap(async (req, res) => {
|
||||
const oldPassword = req.body.old_password || ''
|
||||
const newPassword = req.body.new_password || ''
|
||||
if (newPassword.length < 6) return res.json(fail(2001, 'password too short'))
|
||||
const current = await adminDao.findById(req.admin.admin_id)
|
||||
if (!current) return res.json(fail(1002, '未授权,请重新登录'))
|
||||
let matched = verifyPassword(oldPassword, current.password_hash)
|
||||
if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) {
|
||||
matched = true
|
||||
}
|
||||
if (!matched) return res.json(fail(1001, '原密码错误'))
|
||||
const newHash = hashPassword(newPassword)
|
||||
await adminDao.updatePassword(req.admin.admin_id, newHash)
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: req.ip })
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/devices', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const p = pageParams(ctx)
|
||||
const keyword = (ctx.query.keyword || '').trim()
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw'
|
||||
params.kw = '%' + keyword + '%'
|
||||
}
|
||||
const total = await query('SELECT COUNT(*) AS total FROM devices d' + where, params)
|
||||
const records = await query('SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1' + where + ' ORDER BY d.created_at DESC' + limitClause(p.pageSize, p.offset), params)
|
||||
return ok({ records, total: total[0].total })
|
||||
})
|
||||
// --- Dashboard ---
|
||||
|
||||
router.post('/api/v1/admin/devices', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const deviceId = String(ctx.body.device_id || '').trim()
|
||||
if (!deviceId) return fail(2001, 'device_id required')
|
||||
await query(
|
||||
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
|
||||
{
|
||||
device_id: deviceId,
|
||||
product_id: ctx.body.product_id || 'HOX_LIGHT_MASK',
|
||||
device_secret: ctx.body.device_secret || '',
|
||||
device_name: ctx.body.device_name || '光子美容仪',
|
||||
firmware_version: ctx.body.firmware_version || '1.0.0'
|
||||
}
|
||||
)
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_create', detail: '预生成产品码: ' + deviceId, ip: ctx.ip })
|
||||
return ok({ device_id: deviceId })
|
||||
})
|
||||
router.get('/dashboard', requireAdmin, wrap(async (req, res) => {
|
||||
const counts = await adminDao.getDashboardCounts()
|
||||
const subStats = await adminDao.getSubscriptionStats()
|
||||
res.json(ok({
|
||||
device_count: counts.device_count,
|
||||
user_count: counts.user_count,
|
||||
treatment_count: counts.treatment_count,
|
||||
subscription_count: counts.subscription_count,
|
||||
sub_stats: subStats
|
||||
}))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/admin/devices/batch', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const deviceIds = ctx.body.device_ids
|
||||
if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) return fail(2001, 'device_ids must be an array with 1-500 items')
|
||||
let successCount = 0
|
||||
const failedIds = []
|
||||
for (const id of deviceIds) {
|
||||
const deviceId = String(id || '').trim()
|
||||
if (!deviceId) { failedIds.push(id); continue }
|
||||
try {
|
||||
await query(
|
||||
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
|
||||
{
|
||||
device_id: deviceId,
|
||||
product_id: 'HOX_LIGHT_MASK',
|
||||
device_secret: '',
|
||||
device_name: '光子美容仪',
|
||||
firmware_version: '1.0.0'
|
||||
}
|
||||
)
|
||||
successCount++
|
||||
} catch (err) {
|
||||
failedIds.push(deviceId)
|
||||
}
|
||||
}
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + successCount + '/' + deviceIds.length, ip: ctx.ip })
|
||||
return ok({ created: successCount, failed: failedIds })
|
||||
})
|
||||
// --- Devices ---
|
||||
|
||||
router.get('/api/v1/admin/devices/:device_id', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const device = await one('SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1 WHERE d.device_id = :device_id', { device_id: ctx.params.device_id })
|
||||
if (!device) return fail(1005, 'DEVICE_NOT_FOUND')
|
||||
const bindingHistory = await query('SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC', { device_id: ctx.params.device_id })
|
||||
const recentTreatments = await query('SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id WHERE r.device_id = :device_id ORDER BY r.created_at DESC LIMIT 5', { device_id: ctx.params.device_id })
|
||||
return ok(Object.assign({}, device, { binding_history: bindingHistory, recent_treatments: recentTreatments }))
|
||||
router.get('/devices', requireAdmin, wrap(async (req, res) => {
|
||||
const { page, pageSize, offset } = pageParams(req.query)
|
||||
const { records, total } = await deviceDao.list({
|
||||
keyword: req.query.keyword,
|
||||
pageSize,
|
||||
offset
|
||||
})
|
||||
res.json(ok({ records, total }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/admin/devices/:device_id/unbind', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
await query('UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE device_id = :device_id AND bind_status = 1', { device_id: ctx.params.device_id })
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_unbind', detail: '后台解绑设备: ' + ctx.params.device_id, ip: ctx.ip })
|
||||
return ok({ message: 'success' })
|
||||
router.post('/devices', requireAdmin, wrap(async (req, res) => {
|
||||
const deviceId = String(req.body.device_id || '').trim()
|
||||
if (!deviceId) return res.json(fail(2001, 'device_id required'))
|
||||
await deviceDao.create({
|
||||
device_id: deviceId,
|
||||
product_id: req.body.product_id || 'HOX_LIGHT_MASK',
|
||||
device_secret: req.body.device_secret || '',
|
||||
device_name: req.body.device_name || '光子美容仪',
|
||||
firmware_version: req.body.firmware_version || '1.0.0'
|
||||
})
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_create', detail: '预生成产品码: ' + deviceId, ip: req.ip })
|
||||
res.json(ok({ device_id: deviceId }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/admin/devices/:device_id/command', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const opcode = parseInt(ctx.body.opcode, 10)
|
||||
if (!opcode) return fail(2001, 'opcode required')
|
||||
await query(
|
||||
'INSERT INTO device_commands (device_id, admin_id, opcode, payload_json, status) VALUES (:device_id, :admin_id, :opcode, :payload_json, 1)',
|
||||
{ device_id: ctx.params.device_id, admin_id: admin.admin_id, opcode, payload_json: JSON.stringify(ctx.body) }
|
||||
)
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_command', detail: '记录远程指令: ' + ctx.params.device_id, ip: ctx.ip })
|
||||
return ok({ message: 'queued', command: ctx.body })
|
||||
router.post('/devices/batch', requireAdmin, wrap(async (req, res) => {
|
||||
const deviceIds = req.body.device_ids
|
||||
if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) {
|
||||
return res.json(fail(2001, 'device_ids must be an array with 1-500 items'))
|
||||
}
|
||||
const { created, failed } = await deviceDao.createBatch(deviceIds)
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + created + '/' + deviceIds.length, ip: req.ip })
|
||||
res.json(ok({ created, failed }))
|
||||
}))
|
||||
|
||||
router.get('/devices/:device_id', requireAdmin, wrap(async (req, res) => {
|
||||
const result = await deviceDao.findByIdWithHistory(req.params.device_id)
|
||||
if (!result) return res.json(fail(1005, 'DEVICE_NOT_FOUND'))
|
||||
res.json(ok(result))
|
||||
}))
|
||||
|
||||
router.post('/devices/:device_id/unbind', requireAdmin, wrap(async (req, res) => {
|
||||
await deviceDao.unbind(req.params.device_id)
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_unbind', detail: '后台解绑设备: ' + req.params.device_id, ip: req.ip })
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.post('/devices/:device_id/command', requireAdmin, wrap(async (req, res) => {
|
||||
const opcode = parseInt(req.body.opcode, 10)
|
||||
if (!opcode) return res.json(fail(2001, 'opcode required'))
|
||||
await commandDao.create(req.params.device_id, req.admin.admin_id, opcode, req.body)
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_command', detail: '记录远程指令: ' + req.params.device_id, ip: req.ip })
|
||||
res.json(ok({ message: 'queued', command: req.body }))
|
||||
}))
|
||||
|
||||
router.get('/devices/:device_id/commands', requireAdmin, wrap(async (req, res) => {
|
||||
const { page, pageSize, offset } = pageParams(req.query)
|
||||
const { records, total } = await commandDao.listByDevice(req.params.device_id, { pageSize, offset })
|
||||
res.json(ok({ records, total }))
|
||||
}))
|
||||
|
||||
// --- Users ---
|
||||
|
||||
router.get('/users', requireAdmin, wrap(async (req, res) => {
|
||||
const { page, pageSize, offset } = pageParams(req.query)
|
||||
const { records, total } = await userDao.listAdmin({
|
||||
keyword: req.query.keyword,
|
||||
pageSize,
|
||||
offset
|
||||
})
|
||||
res.json(ok({ records, total }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/devices/:device_id/commands', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const p = pageParams(ctx)
|
||||
const total = await query('SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id', { device_id: ctx.params.device_id })
|
||||
const records = await query(
|
||||
'SELECT command_id, device_id, admin_id, opcode, payload_json, status, created_at, pulled_at, finished_at, result_json FROM device_commands WHERE device_id = :device_id ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset),
|
||||
{ device_id: ctx.params.device_id }
|
||||
)
|
||||
return ok({ records, total: total[0].total })
|
||||
router.get('/users/:user_id', requireAdmin, wrap(async (req, res) => {
|
||||
const result = await userDao.findByIdAdmin(req.params.user_id)
|
||||
if (!result) return res.json(fail(1004, 'USER_NOT_FOUND'))
|
||||
res.json(ok(result))
|
||||
}))
|
||||
|
||||
// --- Subscriptions ---
|
||||
|
||||
router.get('/subscriptions', requireAdmin, wrap(async (req, res) => {
|
||||
const { page, pageSize, offset } = pageParams(req.query)
|
||||
const { records, total } = await subscriptionDao.list({
|
||||
tab: req.query.tab,
|
||||
pageSize,
|
||||
offset
|
||||
})
|
||||
const stats = await subscriptionDao.getStats()
|
||||
res.json(ok({ records, total, stats }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/users', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const p = pageParams(ctx)
|
||||
const keyword = (ctx.query.keyword || '').trim()
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword'
|
||||
params.kw = '%' + keyword + '%'
|
||||
params.keyword = keyword
|
||||
}
|
||||
const total = await query('SELECT COUNT(*) AS total FROM users u' + where, params)
|
||||
const records = await query(
|
||||
'SELECT u.*,' +
|
||||
' (SELECT COUNT(*) FROM bindings WHERE user_id = u.user_id AND bind_status = 1) AS device_count,' +
|
||||
' (SELECT COUNT(*) FROM treatment_records WHERE user_id = u.user_id) AS treatment_count,' +
|
||||
' COALESCE((SELECT status FROM subscriptions WHERE user_id = u.user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1), 0) AS subscription_status' +
|
||||
' FROM users u' + where + ' ORDER BY u.created_at DESC' + limitClause(p.pageSize, p.offset),
|
||||
params
|
||||
)
|
||||
return ok({ records, total: total[0].total })
|
||||
router.post('/subscriptions', requireAdmin, wrap(async (req, res) => {
|
||||
const userId = req.body.user_id
|
||||
if (!userId) return res.json(fail(2001, 'user_id required'))
|
||||
const targetUser = await userDao.findById(userId)
|
||||
if (!targetUser) return res.json(fail(1004, 'user_not_found'))
|
||||
await subscriptionDao.adminCreate(
|
||||
userId,
|
||||
req.body.plan || 'monthly',
|
||||
req.body.amount || 0,
|
||||
req.body.order_id || 'ADMIN' + Date.now(),
|
||||
req.body.days || 30
|
||||
)
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.post('/subscriptions/cancel', requireAdmin, wrap(async (req, res) => {
|
||||
const subscriptionId = req.body.subscription_id
|
||||
if (!subscriptionId) return res.json(fail(2001, 'subscription_id required'))
|
||||
const result = await subscriptionDao.cancel(subscriptionId)
|
||||
if (result.affectedRows === 0) return res.json(fail(2001, '未找到有效订阅'))
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'subscription_cancel', detail: '取消订阅 #' + subscriptionId, ip: req.ip })
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
// --- Treatment Records ---
|
||||
|
||||
router.get('/records', requireAdmin, wrap(async (req, res) => {
|
||||
const { page, pageSize, offset } = pageParams(req.query)
|
||||
const { records, total } = await treatmentDao.listAdmin({
|
||||
keyword: req.query.keyword,
|
||||
dateFrom: req.query.date_from,
|
||||
dateTo: req.query.date_to,
|
||||
pageSize,
|
||||
offset
|
||||
})
|
||||
res.json(ok({ records, total }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/users/:user_id', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: ctx.params.user_id })
|
||||
if (!user) return fail(1004, 'USER_NOT_FOUND')
|
||||
const devices = await query('SELECT d.device_id, d.device_name FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1', { user_id: user.user_id })
|
||||
const treatments = await query('SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5', { user_id: user.user_id })
|
||||
const subscription = await one('SELECT plan, status, start_time, expire_time FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
|
||||
const stats = await one('SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id', { user_id: user.user_id })
|
||||
return ok(Object.assign({}, user, {
|
||||
devices,
|
||||
recent_treatments: treatments,
|
||||
subscription_status: subscription ? subscription.status : 0,
|
||||
subscription_type: subscription ? subscription.plan : null,
|
||||
subscription_expire: subscription ? subscription.expire_time : null,
|
||||
treatment_count: stats ? stats.treatment_count : 0,
|
||||
total_duration: stats ? stats.total_duration : 0
|
||||
}))
|
||||
// --- Logs ---
|
||||
|
||||
router.get('/logs', requireAdmin, wrap(async (req, res) => {
|
||||
const { page, pageSize, offset } = pageParams(req.query)
|
||||
const { records, total } = await logDao.list({
|
||||
type: req.query.type,
|
||||
deviceId: req.query.device_id,
|
||||
pageSize,
|
||||
offset
|
||||
})
|
||||
res.json(ok({ records, total }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/subscriptions', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const p = pageParams(ctx)
|
||||
const tab = (ctx.query.tab || '').trim()
|
||||
let where = ''
|
||||
const params = {}
|
||||
if (tab && tab !== 'all') {
|
||||
if (tab === 'expired') {
|
||||
where = ' WHERE s.status = 2'
|
||||
} else {
|
||||
where = ' WHERE s.plan = :plan'
|
||||
params.plan = tab
|
||||
}
|
||||
}
|
||||
const total = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params)
|
||||
const records = await query(
|
||||
'SELECT s.*, u.nickname FROM subscriptions s LEFT JOIN users u ON u.user_id = s.user_id' + where + ' ORDER BY s.created_at DESC' + limitClause(p.pageSize, p.offset),
|
||||
params
|
||||
)
|
||||
const statsRow = await query('SELECT ' +
|
||||
'SUM(CASE WHEN plan = \'monthly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, ' +
|
||||
'SUM(CASE WHEN plan = \'yearly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, ' +
|
||||
'SUM(CASE WHEN plan = \'trial\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, ' +
|
||||
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
|
||||
'FROM subscriptions', {})
|
||||
return ok({ records, total: total[0].total, stats: statsRow[0] || {} })
|
||||
})
|
||||
// --- Settings ---
|
||||
|
||||
router.post('/api/v1/admin/subscriptions', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const targetUser = await one('SELECT user_id FROM users WHERE user_id = :user_id', { user_id: ctx.body.user_id })
|
||||
if (!targetUser) return fail(1004, 'user_not_found')
|
||||
await query('UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', { user_id: ctx.body.user_id })
|
||||
await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))', {
|
||||
user_id: ctx.body.user_id,
|
||||
plan: ctx.body.plan || 'monthly',
|
||||
amount: ctx.body.amount || 0,
|
||||
order_id: ctx.body.order_id || 'ADMIN' + Date.now(),
|
||||
days: ctx.body.days || 30
|
||||
})
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
router.get('/settings', requireAdmin, wrap(async (req, res) => {
|
||||
const settings = await settingsDao.getAll()
|
||||
res.json(ok(settings))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/admin/subscriptions/cancel', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const subscriptionId = ctx.body.subscription_id
|
||||
if (!subscriptionId) return fail(2001, 'subscription_id required')
|
||||
const result = await query('UPDATE subscriptions SET status = 3 WHERE subscription_id = :subscription_id AND status = 1', { subscription_id: subscriptionId })
|
||||
if (result.affectedRows === 0) return fail(2001, '未找到有效订阅')
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'subscription_cancel', detail: '取消订阅 #' + subscriptionId, ip: ctx.ip })
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
router.post('/settings', requireAdmin, wrap(async (req, res) => {
|
||||
const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode']
|
||||
for (const key of Object.keys(req.body || {})) {
|
||||
if (!ALLOWED_KEYS.includes(key)) continue
|
||||
await settingsDao.update(key, req.body[key])
|
||||
}
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/admin/records', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const p = pageParams(ctx)
|
||||
const keyword = (ctx.query.keyword || '').trim()
|
||||
const dateFrom = (ctx.query.date_from || '').trim()
|
||||
const dateTo = (ctx.query.date_to || '').trim()
|
||||
const conditions = []
|
||||
const params = {}
|
||||
if (keyword) {
|
||||
conditions.push('u.nickname LIKE :kw')
|
||||
params.kw = '%' + keyword + '%'
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push('r.created_at >= :date_from')
|
||||
params.date_from = dateFrom
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push('r.created_at <= :date_to')
|
||||
params.date_to = dateTo
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const total = await query('SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where, params)
|
||||
const records = await query(
|
||||
'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where + ' ORDER BY r.created_at DESC' + limitClause(p.pageSize, p.offset),
|
||||
params
|
||||
)
|
||||
return ok({ records, total: total[0].total })
|
||||
})
|
||||
|
||||
router.get('/api/v1/admin/logs', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const p = pageParams(ctx)
|
||||
const type = (ctx.query.type || '').trim()
|
||||
const deviceId = (ctx.query.device_id || '').trim()
|
||||
const conditions = []
|
||||
const params = {}
|
||||
if (type) {
|
||||
conditions.push('action LIKE :type')
|
||||
params.type = '%' + type + '%'
|
||||
}
|
||||
if (deviceId) {
|
||||
conditions.push('detail LIKE :device_id')
|
||||
params.device_id = '%' + deviceId + '%'
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const total = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params)
|
||||
const records = await query('SELECT * FROM operation_logs' + where + ' ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), params)
|
||||
return ok({ records, total: total[0].total })
|
||||
})
|
||||
|
||||
router.get('/api/v1/admin/settings', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const rows = await query('SELECT setting_key, setting_value FROM system_settings', {})
|
||||
const settings = {}
|
||||
rows.forEach(row => {
|
||||
if (typeof row.setting_value === 'string') {
|
||||
try { settings[row.setting_key] = JSON.parse(row.setting_value) } catch (_) { settings[row.setting_key] = row.setting_value }
|
||||
} else {
|
||||
settings[row.setting_key] = row.setting_value
|
||||
}
|
||||
})
|
||||
return ok(settings)
|
||||
})
|
||||
|
||||
router.post('/api/v1/admin/settings', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode']
|
||||
for (const key of Object.keys(ctx.body || {})) {
|
||||
if (!ALLOWED_KEYS.includes(key)) continue
|
||||
await query('REPLACE INTO system_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value)', { setting_key: key, setting_value: JSON.stringify(ctx.body[key]) })
|
||||
}
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
+59
-62
@@ -1,78 +1,75 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { one, query } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { signUser, readBearer } = require('../lib/auth')
|
||||
const { code2Session } = require('../lib/wechat')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const config = require('../config')
|
||||
const userDao = require('../dao/user.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
function register(router) {
|
||||
router.post('/api/v1/auth/login', async ctx => {
|
||||
const session = await code2Session(ctx.body.code || '')
|
||||
let user = await one('SELECT * FROM users WHERE openid = :openid', { openid: session.openid })
|
||||
if (!user) {
|
||||
const result = await query(
|
||||
'INSERT INTO users (openid, nickname, avatar, status) VALUES (:openid, :nickname, :avatar, 1)',
|
||||
{ openid: session.openid, nickname: '', avatar: '' }
|
||||
)
|
||||
user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: result.insertId })
|
||||
await writeLog({ user_id: user.user_id, action: 'user_register', detail: '新用户注册', ip: ctx.ip })
|
||||
}
|
||||
const token = signUser(user)
|
||||
await writeLog({ user_id: user.user_id, action: 'user_login', detail: '用户登录', ip: ctx.ip })
|
||||
return ok({
|
||||
token,
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
router.post('/auth/login', wrap(async (req, res) => {
|
||||
const session = await code2Session(req.body.code || '')
|
||||
let user = await userDao.findByOpenid(session.openid)
|
||||
if (!user) {
|
||||
const result = await userDao.create(session.openid)
|
||||
user = await userDao.findById(result.insertId)
|
||||
await logDao.write({ user_id: user.user_id, action: 'user_register', detail: '新用户注册', ip: req.ip })
|
||||
}
|
||||
const token = signUser(user)
|
||||
await logDao.write({ user_id: user.user_id, action: 'user_login', detail: '用户登录', ip: req.ip })
|
||||
res.json(ok({
|
||||
token,
|
||||
user_id: String(user.user_id),
|
||||
user_info: {
|
||||
user_id: String(user.user_id),
|
||||
user_info: {
|
||||
user_id: String(user.user_id),
|
||||
nickname: user.nickname || '用户' + String(user.user_id),
|
||||
avatar: user.avatar || '',
|
||||
phone: user.phone || '',
|
||||
gender: user.gender || 0
|
||||
},
|
||||
expires_in: 604800
|
||||
})
|
||||
})
|
||||
nickname: user.nickname || '用户' + String(user.user_id),
|
||||
avatar: user.avatar || '',
|
||||
phone: user.phone || '',
|
||||
gender: user.gender || 0
|
||||
},
|
||||
expires_in: 604800
|
||||
}))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/auth/refresh', async ctx => {
|
||||
const token = readBearer(ctx.headers)
|
||||
if (!token) return fail(1001, 'token_expired')
|
||||
router.post('/auth/refresh', wrap(async (req, res) => {
|
||||
const token = readBearer(req.headers)
|
||||
if (!token) return res.json(fail(1001, 'token_expired'))
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = jwt.verify(token, config.jwt.secret)
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
try {
|
||||
payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true })
|
||||
} catch (_) {
|
||||
return fail(1001, 'token_expired')
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const gracePeriod = 3 * 24 * 60 * 60
|
||||
if (now - payload.exp > gracePeriod) {
|
||||
return fail(1001, 'token_expired')
|
||||
}
|
||||
} else {
|
||||
return fail(1001, 'token_expired')
|
||||
let payload
|
||||
try {
|
||||
payload = jwt.verify(token, config.jwt.secret)
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
try {
|
||||
payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true })
|
||||
} catch (_) {
|
||||
return res.json(fail(1001, 'token_expired'))
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const gracePeriod = 3 * 24 * 60 * 60
|
||||
if (now - payload.exp > gracePeriod) {
|
||||
return res.json(fail(1001, 'token_expired'))
|
||||
}
|
||||
} else {
|
||||
return res.json(fail(1001, 'token_expired'))
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.type !== 'user') return fail(1001, 'token_expired')
|
||||
if (payload.type !== 'user') return res.json(fail(1001, 'token_expired'))
|
||||
|
||||
// Check if token is within 7 days of expiry (for non-expired tokens)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const sevenDays = 7 * 24 * 60 * 60
|
||||
if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) {
|
||||
return ok({ token, expires_in: payload.exp - now })
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const sevenDays = 7 * 24 * 60 * 60
|
||||
if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) {
|
||||
return res.json(ok({ token, expires_in: payload.exp - now }))
|
||||
}
|
||||
|
||||
const user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
|
||||
if (!user) return fail(1001, 'token_expired')
|
||||
const user = await userDao.findById(payload.user_id)
|
||||
if (!user) return res.json(fail(1001, 'token_expired'))
|
||||
|
||||
const newToken = signUser(user)
|
||||
return ok({ token: newToken, expires_in: 604800 })
|
||||
})
|
||||
}
|
||||
const newToken = signUser(user)
|
||||
res.json(ok({ token: newToken, expires_in: 604800 }))
|
||||
}))
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
+124
-165
@@ -1,176 +1,135 @@
|
||||
const { one, query, transaction } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser, randomHex } = require('../lib/auth')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const { randomHex } = require('../lib/auth')
|
||||
const bindingDao = require('../dao/binding.dao')
|
||||
const deviceDao = require('../dao/device.dao')
|
||||
const commandDao = require('../dao/command.dao')
|
||||
const subscriptionDao = require('../dao/subscription.dao')
|
||||
const deviceEventDao = require('../dao/device-event.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
async function ensureTrial(conn, userId) {
|
||||
const [subs] = await conn.execute('SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1', [userId])
|
||||
if (subs.length > 0) return
|
||||
await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, ?, 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', [userId, 'trial'])
|
||||
}
|
||||
router.post('/device/bind', requireUser, wrap(async (req, res) => {
|
||||
const deviceId = String(req.body.device_id || '').trim()
|
||||
if (!deviceId) return res.json(fail(2001, 'device_id required'))
|
||||
|
||||
function register(router) {
|
||||
router.post('/api/v1/device/bind', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const deviceId = String(ctx.body.device_id || '').trim()
|
||||
if (!deviceId) return fail(2001, 'device_id required')
|
||||
const active = await bindingDao.findActiveByUser(req.user.user_id)
|
||||
if (active) return res.json(fail(2001, '已绑定设备', { device_id: active.device_id }))
|
||||
|
||||
const result = await transaction(async conn => {
|
||||
const [active] = await conn.execute('SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', [user.user_id])
|
||||
if (active.length > 0) return { duplicated: true, device_id: active[0].device_id }
|
||||
const device = await bindingDao.findDeviceExists(deviceId)
|
||||
if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND'))
|
||||
|
||||
const [devices] = await conn.execute('SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', [deviceId])
|
||||
if (devices.length === 0) return { invalid: true }
|
||||
const bindToken = randomHex(8)
|
||||
await bindingDao.createPending(req.user.user_id, deviceId, bindToken)
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: req.ip })
|
||||
|
||||
const bindToken = randomHex(8)
|
||||
await conn.execute(
|
||||
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())',
|
||||
[user.user_id, deviceId, bindToken]
|
||||
)
|
||||
return { device_id: deviceId, bind_token: bindToken }
|
||||
})
|
||||
const sub = await subscriptionDao.findActive(req.user.user_id)
|
||||
res.json(ok({
|
||||
device_id: deviceId,
|
||||
bind_token: bindToken,
|
||||
subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 }
|
||||
}))
|
||||
}))
|
||||
|
||||
if (result.invalid) return fail(1005, 'DEVICE_NOT_FOUND')
|
||||
if (result.duplicated) return fail(2001, '已绑定设备', { device_id: result.device_id })
|
||||
await writeLog({ user_id: user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: ctx.ip })
|
||||
const sub = await one('SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
|
||||
return ok(Object.assign(result, { subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } }))
|
||||
router.post('/device/bind/confirm', requireUser, wrap(async (req, res) => {
|
||||
const deviceId = String(req.body.device_id || '').trim()
|
||||
const bindToken = String(req.body.bind_token || '').trim()
|
||||
if (!deviceId || !bindToken) return res.json(fail(2001, 'device_id and bind_token required'))
|
||||
|
||||
const confirmed = await bindingDao.confirmBind(req.user.user_id, deviceId, bindToken)
|
||||
if (!confirmed) return res.json(fail(2001, 'bind_token invalid or expired'))
|
||||
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'device_bind_confirm', detail: '确认绑定设备: ' + deviceId, ip: req.ip })
|
||||
const sub = await subscriptionDao.findActive(req.user.user_id)
|
||||
res.json(ok({
|
||||
message: 'success',
|
||||
subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 }
|
||||
}))
|
||||
}))
|
||||
|
||||
router.post('/device/mock-bind', requireUser, wrap(async (req, res) => {
|
||||
const config = require('../config')
|
||||
if (config.nodeEnv === 'production') return res.json(fail(2001, 'not available in production'))
|
||||
const deviceId = String(req.body.device_id || '').trim()
|
||||
if (!deviceId) return res.json(fail(2001, 'device_id required'))
|
||||
|
||||
const active = await bindingDao.findActiveByUser(req.user.user_id)
|
||||
if (active) return res.json(fail(2001, '已绑定设备', { device_id: active.device_id }))
|
||||
|
||||
const device = await bindingDao.findDeviceExists(deviceId)
|
||||
if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND'))
|
||||
|
||||
await bindingDao.mockBind(req.user.user_id, deviceId)
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'device_bind_confirm', detail: '模拟绑定设备: ' + deviceId, ip: req.ip })
|
||||
res.json(ok({ message: 'success', device_id: deviceId }))
|
||||
}))
|
||||
|
||||
router.post('/device/unbind', requireUser, wrap(async (req, res) => {
|
||||
const deviceId = req.body.device_id || null
|
||||
await bindingDao.unbindByUser(req.user.user_id, deviceId)
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'device_unbind', detail: '解绑设备: ' + (deviceId || 'current'), ip: req.ip })
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.get('/device/list', requireUser, wrap(async (req, res) => {
|
||||
const devices = await deviceDao.listByUser(req.user.user_id)
|
||||
res.json(ok({ devices, total: devices.length }))
|
||||
}))
|
||||
|
||||
router.get('/device/command/pending', requireUser, wrap(async (req, res) => {
|
||||
const deviceId = String(req.query.device_id || '').trim()
|
||||
if (!deviceId) return res.json(fail(2001, 'device_id required'))
|
||||
|
||||
const active = await bindingDao.findActiveByUser(req.user.user_id)
|
||||
if (!active || active.device_id !== deviceId) return res.json(fail(1006, 'DEVICE_NOT_BOUND'))
|
||||
|
||||
const commands = await commandDao.getPending(deviceId)
|
||||
if (commands.length > 0) {
|
||||
await commandDao.markPulled(commands.map(c => c.command_id))
|
||||
}
|
||||
res.json(ok({
|
||||
commands: commands.map(c => ({
|
||||
seq: c.command_id,
|
||||
opcode: c.opcode,
|
||||
payload: typeof c.payload_json === 'string' ? JSON.parse(c.payload_json) : c.payload_json || {}
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
|
||||
router.post('/device/command/result', requireUser, wrap(async (req, res) => {
|
||||
const commandId = parseInt(req.body.command_id || req.body.seq, 10)
|
||||
const success = req.body.success !== false
|
||||
if (!commandId) return res.json(fail(2001, 'command_id required'))
|
||||
// commandDao.finish verifies device ownership via user binding
|
||||
await commandDao.finish(commandId, success, JSON.stringify(req.body), req.user.user_id)
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.post('/device/event', requireUser, wrap(async (req, res) => {
|
||||
const deviceId = String(req.body.device_id || '').trim()
|
||||
if (!deviceId) return res.json(fail(2001, 'device_id required'))
|
||||
|
||||
const active = await bindingDao.findActiveByUser(req.user.user_id)
|
||||
if (!active || active.device_id !== deviceId) return res.json(fail(1006, 'device_not_bound'))
|
||||
|
||||
await deviceEventDao.create({
|
||||
device_id: deviceId,
|
||||
user_id: req.user.user_id,
|
||||
event_type: req.body.event_type || 'device_error',
|
||||
error_code: req.body.error_code || null,
|
||||
temperature: req.body.temperature || null,
|
||||
payload: req.body
|
||||
})
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'device_event', detail: '设备事件: ' + deviceId, ip: req.ip })
|
||||
res.json(ok({ message: 'ok' }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/device/bind/confirm', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const deviceId = String(ctx.body.device_id || '').trim()
|
||||
const bindToken = String(ctx.body.bind_token || '').trim()
|
||||
if (!deviceId || !bindToken) return fail(2001, 'device_id and bind_token required')
|
||||
router.get('/device/:device_id', requireUser, wrap(async (req, res) => {
|
||||
const device = await deviceDao.findBoundDevice(req.user.user_id, req.params.device_id)
|
||||
if (!device) return res.json(fail(1006, 'DEVICE_NOT_BOUND'))
|
||||
res.json(ok(device))
|
||||
}))
|
||||
|
||||
const updated = await transaction(async conn => {
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT binding_id FROM bindings WHERE user_id = ? AND device_id = ? AND bind_token = ? AND bind_status = 3 AND bind_expires > NOW() LIMIT 1',
|
||||
[user.user_id, deviceId, bindToken]
|
||||
)
|
||||
if (rows.length === 0) return false
|
||||
await conn.execute('UPDATE bindings SET bind_status = 1, bind_time = NOW() WHERE binding_id = ?', [rows[0].binding_id])
|
||||
await ensureTrial(conn, user.user_id)
|
||||
return true
|
||||
})
|
||||
if (!updated) return fail(2001, 'bind_token invalid or expired')
|
||||
await writeLog({ user_id: user.user_id, action: 'device_bind_confirm', detail: '确认绑定设备: ' + deviceId, ip: ctx.ip })
|
||||
const sub = await one('SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
|
||||
return ok({ message: 'success', subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } })
|
||||
})
|
||||
|
||||
router.post('/api/v1/device/mock-bind', async ctx => {
|
||||
const config = require('../config')
|
||||
if (config.nodeEnv === 'production') return fail(2001, 'not available in production')
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const deviceId = String(ctx.body.device_id || '').trim()
|
||||
if (!deviceId) return fail(2001, 'device_id required')
|
||||
|
||||
const result = await transaction(async conn => {
|
||||
const [active] = await conn.execute('SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', [user.user_id])
|
||||
if (active.length > 0) return { duplicated: true, device_id: active[0].device_id }
|
||||
const [devices] = await conn.execute('SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', [deviceId])
|
||||
if (devices.length === 0) return { invalid: true }
|
||||
await conn.execute('UPDATE bindings SET bind_status = 2 WHERE user_id = ? AND bind_status = 3', [user.user_id])
|
||||
await conn.execute('INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, NOW(), 1, NOW())', [user.user_id, deviceId, 'mock'])
|
||||
await ensureTrial(conn, user.user_id)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
if (result.invalid) return fail(1005, 'DEVICE_NOT_FOUND')
|
||||
if (result.duplicated) return fail(2001, '已绑定设备', { device_id: result.device_id })
|
||||
await writeLog({ user_id: user.user_id, action: 'device_bind_confirm', detail: '模拟绑定设备: ' + deviceId, ip: ctx.ip })
|
||||
return ok({ message: 'success', device_id: deviceId })
|
||||
})
|
||||
|
||||
router.post('/api/v1/device/unbind', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const deviceId = ctx.body.device_id || null
|
||||
await query(
|
||||
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = :user_id AND bind_status = 1 AND (:device_id IS NULL OR device_id = :device_id)',
|
||||
{ user_id: user.user_id, device_id: deviceId }
|
||||
)
|
||||
await writeLog({ user_id: user.user_id, action: 'device_unbind', detail: '解绑设备: ' + (deviceId || 'current'), ip: ctx.ip })
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
|
||||
router.get('/api/v1/device/list', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const devices = await query(
|
||||
'SELECT d.device_id, d.device_name, d.status, d.battery, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 ORDER BY b.bind_time DESC',
|
||||
{ user_id: user.user_id }
|
||||
)
|
||||
return ok({ devices, total: devices.length })
|
||||
})
|
||||
|
||||
router.get('/api/v1/device/command/pending', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const deviceId = String(ctx.query.device_id || '').trim()
|
||||
if (!deviceId) return fail(2001, 'device_id required')
|
||||
const bound = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: deviceId })
|
||||
if (!bound) return fail(1006, 'DEVICE_NOT_BOUND')
|
||||
const commands = await query('SELECT command_id, opcode, payload_json FROM device_commands WHERE device_id = :device_id AND status = 1 ORDER BY created_at ASC LIMIT 10', { device_id: deviceId })
|
||||
if (commands.length > 0) {
|
||||
await query('UPDATE device_commands SET status = 2, pulled_at = NOW() WHERE command_id IN (' + commands.map(c => Number(c.command_id)).join(',') + ')', {})
|
||||
}
|
||||
return ok({ commands: commands.map(c => ({ seq: c.command_id, opcode: c.opcode, payload: typeof c.payload_json === 'string' ? JSON.parse(c.payload_json) : c.payload_json || {} })) })
|
||||
})
|
||||
|
||||
router.post('/api/v1/device/command/result', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const commandId = parseInt(ctx.body.command_id || ctx.body.seq, 10)
|
||||
const success = ctx.body.success !== false
|
||||
if (!commandId) return fail(2001, 'command_id required')
|
||||
const cmd = await one('SELECT dc.command_id FROM device_commands dc JOIN bindings b ON b.device_id = dc.device_id AND b.user_id = :user_id AND b.bind_status = 1 WHERE dc.command_id = :command_id', { user_id: user.user_id, command_id: commandId })
|
||||
if (!cmd) return fail(1006, 'device_not_bound')
|
||||
await query('UPDATE device_commands SET status = :status, finished_at = NOW(), result_json = :result_json WHERE command_id = :command_id', {
|
||||
command_id: commandId,
|
||||
status: success ? 3 : 4,
|
||||
result_json: JSON.stringify(ctx.body)
|
||||
})
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
|
||||
router.get('/api/v1/device/:device_id', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const device = await one(
|
||||
'SELECT d.device_id, d.device_name, d.status, d.battery, d.temperature, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 AND b.device_id = :device_id',
|
||||
{ user_id: user.user_id, device_id: ctx.params.device_id }
|
||||
)
|
||||
if (!device) return fail(1006, 'DEVICE_NOT_BOUND')
|
||||
return ok(device)
|
||||
})
|
||||
|
||||
router.post('/api/v1/device/event', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const deviceId = String(ctx.body.device_id || '').trim()
|
||||
if (!deviceId) return fail(2001, 'device_id required')
|
||||
const binding = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: deviceId })
|
||||
if (!binding) return fail(1006, 'device_not_bound')
|
||||
await query(
|
||||
'INSERT INTO device_events (device_id, user_id, event_type, error_code, temperature, payload_json) VALUES (:device_id, :user_id, :event_type, :error_code, :temperature, :payload_json)',
|
||||
{
|
||||
device_id: deviceId,
|
||||
user_id: user.user_id,
|
||||
event_type: ctx.body.event_type || 'device_error',
|
||||
error_code: ctx.body.error_code || null,
|
||||
temperature: ctx.body.temperature || null,
|
||||
payload_json: JSON.stringify(ctx.body)
|
||||
}
|
||||
)
|
||||
await writeLog({ user_id: user.user_id, action: 'device_event', detail: '设备事件: ' + deviceId, ip: ctx.ip })
|
||||
return ok({ message: 'ok' })
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
+48
-57
@@ -1,64 +1,55 @@
|
||||
const { one, query } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser, requireAdmin } = require('../lib/auth')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { getObjectUrl } = require('../lib/cos')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const firmwareDao = require('../dao/firmware.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
function register(router) {
|
||||
router.get('/api/v1/admin/firmware', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const rows = await query('SELECT firmware_id, version, device_type, cos_key, size_bytes, sha256, status, created_at FROM firmware_files ORDER BY created_at DESC', {})
|
||||
return ok({ records: rows, total: rows.length })
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
router.get('/admin/firmware', requireAdmin, wrap(async (req, res) => {
|
||||
const rows = await firmwareDao.list()
|
||||
res.json(ok({ records: rows, total: rows.length }))
|
||||
}))
|
||||
|
||||
router.post('/admin/firmware', requireAdmin, wrap(async (req, res) => {
|
||||
const version = String(req.body.version || '').trim()
|
||||
const cosKey = String(req.body.cos_key || '').trim()
|
||||
if (!version || !cosKey) return res.json(fail(2001, 'version and cos_key required'))
|
||||
const insertId = await firmwareDao.create({
|
||||
version,
|
||||
device_type: req.body.device_type || '',
|
||||
cos_key: cosKey,
|
||||
size_bytes: Number(req.body.size_bytes || 0),
|
||||
sha256: req.body.sha256 || '',
|
||||
status: req.body.status === 0 ? 0 : 1
|
||||
})
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_firmware_create', detail: '登记固件: ' + version, ip: req.ip })
|
||||
res.json(ok({ firmware_id: insertId }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/admin/firmware', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const version = String(ctx.body.version || '').trim()
|
||||
const cosKey = String(ctx.body.cos_key || '').trim()
|
||||
if (!version || !cosKey) return fail(2001, 'version and cos_key required')
|
||||
const result = await query(
|
||||
'INSERT INTO firmware_files (version, device_type, cos_key, size_bytes, sha256, status) VALUES (:version, :device_type, :cos_key, :size_bytes, :sha256, :status)',
|
||||
{
|
||||
version,
|
||||
device_type: ctx.body.device_type || '',
|
||||
cos_key: cosKey,
|
||||
size_bytes: Number(ctx.body.size_bytes || 0),
|
||||
sha256: ctx.body.sha256 || '',
|
||||
status: ctx.body.status === 0 ? 0 : 1
|
||||
}
|
||||
)
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_firmware_create', detail: '登记固件: ' + version, ip: ctx.ip })
|
||||
return ok({ firmware_id: result.insertId })
|
||||
})
|
||||
router.post('/admin/firmware/:firmware_id/status', requireAdmin, wrap(async (req, res) => {
|
||||
const firmwareId = parseInt(req.params.firmware_id, 10)
|
||||
const status = Number(req.body.status) === 1 ? 1 : 0
|
||||
if (!firmwareId) return res.json(fail(2001, 'firmware_id required'))
|
||||
await firmwareDao.updateStatus(firmwareId, status)
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_firmware_status', detail: '更新固件状态: ' + firmwareId + ' -> ' + status, ip: req.ip })
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/admin/firmware/:firmware_id/status', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const firmwareId = parseInt(ctx.params.firmware_id, 10)
|
||||
const status = Number(ctx.body.status) === 1 ? 1 : 0
|
||||
if (!firmwareId) return fail(2001, 'firmware_id required')
|
||||
await query('UPDATE firmware_files SET status = :status WHERE firmware_id = :firmware_id', { status, firmware_id: firmwareId })
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_firmware_status', detail: '更新固件状态: ' + firmwareId + ' -> ' + status, ip: ctx.ip })
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
router.get('/firmware/latest', requireUser, wrap(async (req, res) => {
|
||||
const firmware = await firmwareDao.findLatest()
|
||||
if (!firmware) return res.json(ok({ has_update: false }))
|
||||
const currentVersion = req.query.current_version || ''
|
||||
if (currentVersion && currentVersion === firmware.version) return res.json(ok({ has_update: false }))
|
||||
res.json(ok({
|
||||
has_update: true,
|
||||
version: firmware.version,
|
||||
size_bytes: firmware.size_bytes,
|
||||
sha256: firmware.sha256,
|
||||
download_url: await getObjectUrl(firmware.cos_key, 600)
|
||||
}))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/firmware/latest', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const firmware = await one('SELECT * FROM firmware_files WHERE status = 1 ORDER BY created_at DESC LIMIT 1', {})
|
||||
if (!firmware) return ok({ has_update: false })
|
||||
const currentVersion = ctx.query.current_version || ''
|
||||
if (currentVersion && currentVersion === firmware.version) return ok({ has_update: false })
|
||||
return ok({
|
||||
has_update: true,
|
||||
version: firmware.version,
|
||||
size_bytes: firmware.size_bytes,
|
||||
sha256: firmware.sha256,
|
||||
download_url: await getObjectUrl(firmware.cos_key, 600)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
const { one, query, transaction } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser, requireAdmin } = require('../lib/auth')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const subscriptionDao = require('../dao/subscription.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
const PLANS = {
|
||||
trial: { amount: 0, days: 7 },
|
||||
@@ -9,54 +13,44 @@ const PLANS = {
|
||||
yearly: { amount: 899, days: 365 }
|
||||
}
|
||||
|
||||
function register(router) {
|
||||
router.get('/api/v1/subscription', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const sub = await one('SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
|
||||
if (!sub) return ok({ status: 'inactive', plan: 'none', remaining_days: 0 })
|
||||
return ok({ status: sub.remaining_days > 0 ? 'active' : 'expired', plan: sub.plan, start_time: sub.start_time, expire_time: sub.expire_time, remaining_days: sub.remaining_days })
|
||||
})
|
||||
router.get('/subscription', requireUser, wrap(async (req, res) => {
|
||||
const sub = await subscriptionDao.findActive(req.user.user_id)
|
||||
if (!sub) return res.json(ok({ status: 'inactive', plan: 'none', remaining_days: 0 }))
|
||||
res.json(ok({
|
||||
status: sub.remaining_days > 0 ? 'active' : 'expired',
|
||||
plan: sub.plan,
|
||||
start_time: sub.start_time,
|
||||
expire_time: sub.expire_time,
|
||||
remaining_days: sub.remaining_days
|
||||
}))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/subscription/purchase', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const plan = ctx.body.plan || ctx.body.plan_type
|
||||
if (!PLANS[plan]) return fail(2001, 'invalid plan')
|
||||
const orderId = 'ORD' + Date.now()
|
||||
return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount })
|
||||
})
|
||||
router.post('/subscription/purchase', requireUser, wrap(async (req, res) => {
|
||||
const plan = req.body.plan || req.body.plan_type
|
||||
if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan'))
|
||||
const orderId = 'ORD' + Date.now()
|
||||
res.json(ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/subscription/trial', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const usedTrial = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = \'trial\' LIMIT 1', { user_id: user.user_id })
|
||||
if (usedTrial) return fail(2001, '已使用过试用')
|
||||
const activeSub = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1', { user_id: user.user_id })
|
||||
if (activeSub) return fail(2001, '已有有效订阅')
|
||||
const orderId = 'TRIAL' + Date.now()
|
||||
await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, \'trial\', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', { user_id: user.user_id, order_id: orderId })
|
||||
return ok({ status: 'active', plan: 'trial', remaining_days: 7 })
|
||||
})
|
||||
router.post('/subscription/trial', requireUser, wrap(async (req, res) => {
|
||||
const usedTrial = await subscriptionDao.findTrial(req.user.user_id)
|
||||
if (usedTrial) return res.json(fail(2001, '已使用过试用'))
|
||||
const activeSub = await subscriptionDao.findActive(req.user.user_id)
|
||||
if (activeSub) return res.json(fail(2001, '已有有效订阅'))
|
||||
await subscriptionDao.createTrial(req.user.user_id)
|
||||
res.json(ok({ status: 'active', plan: 'trial', remaining_days: 7 }))
|
||||
}))
|
||||
|
||||
// Temporary: admin-only until payment integration
|
||||
router.post('/api/v1/subscription/verify', async ctx => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
if (!admin) return fail(1002, '未授权,请重新登录')
|
||||
const userId = ctx.body.user_id
|
||||
if (!userId) return fail(2001, 'user_id required')
|
||||
const plan = ctx.body.plan || ctx.body.plan_type || 'monthly'
|
||||
if (!PLANS[plan]) return fail(2001, 'invalid plan')
|
||||
const p = PLANS[plan]
|
||||
await transaction(async conn => {
|
||||
await conn.execute('UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1', [userId])
|
||||
await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))', [
|
||||
userId, plan, p.amount, ctx.body.order_id || 'ORD' + Date.now(), p.days
|
||||
])
|
||||
})
|
||||
await writeLog({ admin_id: admin.admin_id, action: 'subscription_verify', detail: '订阅生效: ' + plan + ' user:' + userId, ip: ctx.ip })
|
||||
return ok({ status: 'active', plan, remaining_days: p.days })
|
||||
})
|
||||
}
|
||||
// Temporary: admin-only until payment integration
|
||||
router.post('/subscription/verify', requireAdmin, wrap(async (req, res) => {
|
||||
const userId = req.body.user_id
|
||||
if (!userId) return res.json(fail(2001, 'user_id required'))
|
||||
const plan = req.body.plan || req.body.plan_type || 'monthly'
|
||||
if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan'))
|
||||
const p = PLANS[plan]
|
||||
await subscriptionDao.purchase(userId, plan, p.amount, req.body.order_id || 'ORD' + Date.now(), p.days)
|
||||
await logDao.write({ admin_id: req.admin.admin_id, action: 'subscription_verify', detail: '订阅生效: ' + plan + ' user:' + userId, ip: req.ip })
|
||||
res.json(ok({ status: 'active', plan, remaining_days: p.days }))
|
||||
}))
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
+51
-61
@@ -1,68 +1,58 @@
|
||||
const { one, query, limitClause } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser } = require('../lib/auth')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const { toMysqlDate } = require('../lib/utils')
|
||||
const treatmentDao = require('../dao/treatment.dao')
|
||||
const bindingDao = require('../dao/binding.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
function register(router) {
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
router.get('/api/v1/treatment/history', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const page = Math.max(1, parseInt(ctx.query.page, 10) || 1)
|
||||
const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size, 10) || 20), 100)
|
||||
const offset = (page - 1) * pageSize
|
||||
const total = await query('SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id', { user_id: user.user_id })
|
||||
const records = await query('SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC' + limitClause(pageSize, offset), { user_id: user.user_id })
|
||||
return ok({ total: total[0].total, page, page_size: pageSize, records })
|
||||
router.get('/treatment/history', requireUser, wrap(async (req, res) => {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
|
||||
const pageSize = Math.min(Math.max(1, parseInt(req.query.page_size, 10) || 20), 100)
|
||||
const offset = (page - 1) * pageSize
|
||||
const { records, total } = await treatmentDao.listByUser(req.user.user_id, { pageSize, offset })
|
||||
res.json(ok({ total, page, page_size: pageSize, records }))
|
||||
}))
|
||||
|
||||
router.post('/treatment/sync', requireUser, wrap(async (req, res) => {
|
||||
const d = req.body || {}
|
||||
if (!d.device_id) return res.json(fail(2001, 'device_id required'))
|
||||
|
||||
const active = await bindingDao.findActiveByUser(req.user.user_id)
|
||||
if (!active || active.device_id !== d.device_id) return res.json(fail(1006, 'device_not_bound'))
|
||||
|
||||
const sessionId = d.session_id || 'SESS' + Date.now()
|
||||
await treatmentDao.create({
|
||||
session_id: sessionId,
|
||||
device_id: d.device_id,
|
||||
user_id: req.user.user_id,
|
||||
start_time: toMysqlDate(d.start_time),
|
||||
end_time: toMysqlDate(d.end_time),
|
||||
regions: Array.isArray(d.regions) ? d.regions.join(',') : String(d.regions || ''),
|
||||
total_duration_ms: parseInt(d.total_duration_ms, 10) || 0,
|
||||
mode: parseInt(d.mode, 10) || 0,
|
||||
avg_pd: Number(d.avg_pd) || 0,
|
||||
battery: d.battery == null ? null : parseInt(d.battery, 10),
|
||||
temperature: d.temperature == null ? null : parseInt(d.temperature, 10),
|
||||
wavelength: d.wavelength == null ? null : parseInt(d.wavelength, 10),
|
||||
brightness: d.brightness == null ? null : parseInt(d.brightness, 10),
|
||||
pd_json: JSON.stringify(d.pd_values || {})
|
||||
})
|
||||
await treatmentDao.updateDevice(
|
||||
d.device_id,
|
||||
d.battery == null ? null : parseInt(d.battery, 10),
|
||||
d.temperature == null ? null : parseInt(d.temperature, 10)
|
||||
)
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: req.ip })
|
||||
res.json(ok({ record_id: sessionId }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/treatment/sync', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const d = ctx.body || {}
|
||||
if (!d.device_id) return fail(2001, 'device_id required')
|
||||
const binding = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: d.device_id })
|
||||
if (!binding) return fail(1006, 'device_not_bound')
|
||||
const sessionId = d.session_id || 'SESS' + Date.now()
|
||||
await query(
|
||||
`INSERT INTO treatment_records
|
||||
(session_id, device_id, user_id, start_time, end_time, regions, total_duration_ms, mode, avg_pd, battery, temperature, wavelength, brightness, pd_json)
|
||||
VALUES (:session_id, :device_id, :user_id, :start_time, :end_time, :regions, :total_duration_ms, :mode, :avg_pd, :battery, :temperature, :wavelength, :brightness, :pd_json)
|
||||
ON DUPLICATE KEY UPDATE end_time = VALUES(end_time), total_duration_ms = VALUES(total_duration_ms), avg_pd = VALUES(avg_pd), battery = VALUES(battery), temperature = VALUES(temperature), pd_json = VALUES(pd_json)`,
|
||||
{
|
||||
session_id: sessionId,
|
||||
device_id: d.device_id,
|
||||
user_id: user.user_id,
|
||||
start_time: toMysqlDate(d.start_time),
|
||||
end_time: toMysqlDate(d.end_time),
|
||||
regions: Array.isArray(d.regions) ? d.regions.join(',') : String(d.regions || ''),
|
||||
total_duration_ms: parseInt(d.total_duration_ms, 10) || 0,
|
||||
mode: parseInt(d.mode, 10) || 0,
|
||||
avg_pd: Number(d.avg_pd) || 0,
|
||||
battery: d.battery == null ? null : parseInt(d.battery, 10),
|
||||
temperature: d.temperature == null ? null : parseInt(d.temperature, 10),
|
||||
wavelength: d.wavelength == null ? null : parseInt(d.wavelength, 10),
|
||||
brightness: d.brightness == null ? null : parseInt(d.brightness, 10),
|
||||
pd_json: JSON.stringify(d.pd_values || {})
|
||||
}
|
||||
)
|
||||
await query('UPDATE devices SET battery = COALESCE(:battery, battery), temperature = COALESCE(:temperature, temperature), last_online_at = NOW() WHERE device_id = :device_id', {
|
||||
device_id: d.device_id,
|
||||
battery: d.battery == null ? null : parseInt(d.battery, 10),
|
||||
temperature: d.temperature == null ? null : parseInt(d.temperature, 10)
|
||||
})
|
||||
await writeLog({ user_id: user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: ctx.ip })
|
||||
return ok({ record_id: sessionId })
|
||||
})
|
||||
router.get('/treatment/:record_id', requireUser, wrap(async (req, res) => {
|
||||
const record = await treatmentDao.findBySession(req.params.record_id, req.user.user_id)
|
||||
if (!record) return res.json(fail(1005, 'record_not_found'))
|
||||
res.json(ok(record))
|
||||
}))
|
||||
|
||||
router.get('/api/v1/treatment/:record_id', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const record = await one('SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id', { session_id: ctx.params.record_id, user_id: user.user_id })
|
||||
if (!record) return fail(1005, 'record_not_found')
|
||||
return ok(record)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
+39
-46
@@ -1,52 +1,45 @@
|
||||
const { query } = require('../lib/db')
|
||||
const router = require('express').Router()
|
||||
const { ok, fail } = require('../lib/response')
|
||||
const { requireUser } = require('../lib/auth')
|
||||
const { writeLog } = require('../lib/log')
|
||||
const { requireUser } = require('../middleware/auth')
|
||||
const { getPhoneNumber } = require('../lib/wechat')
|
||||
const userDao = require('../dao/user.dao')
|
||||
const deviceDao = require('../dao/device.dao')
|
||||
const logDao = require('../dao/log.dao')
|
||||
|
||||
function register(router) {
|
||||
router.get('/api/v1/user/profile', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const binds = await query('SELECT COUNT(*) AS total FROM bindings WHERE user_id = :user_id AND bind_status = 1', { user_id: user.user_id })
|
||||
return ok({
|
||||
user_id: String(user.user_id),
|
||||
nickname: user.nickname || '用户' + String(user.user_id),
|
||||
avatar: user.avatar || '',
|
||||
phone: user.phone || '',
|
||||
gender: user.gender || 0,
|
||||
bind_time: null,
|
||||
device_count: binds[0].total
|
||||
})
|
||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||
|
||||
router.get('/user/profile', requireUser, wrap(async (req, res) => {
|
||||
const user = req.user
|
||||
const devices = await deviceDao.listByUser(user.user_id)
|
||||
res.json(ok({
|
||||
user_id: String(user.user_id),
|
||||
nickname: user.nickname || '用户' + String(user.user_id),
|
||||
avatar: user.avatar || '',
|
||||
phone: user.phone || '',
|
||||
gender: user.gender || 0,
|
||||
bind_time: null,
|
||||
device_count: devices.length
|
||||
}))
|
||||
}))
|
||||
|
||||
router.put('/user/profile', requireUser, wrap(async (req, res) => {
|
||||
await userDao.updateProfile(req.user.user_id, {
|
||||
nickname: req.body.nickname || null,
|
||||
avatar: req.body.avatar || req.body.avatar_url || null,
|
||||
gender: req.body.gender === undefined ? null : req.body.gender
|
||||
})
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'user_update', detail: '更新用户资料', ip: req.ip })
|
||||
res.json(ok({ message: 'success' }))
|
||||
}))
|
||||
|
||||
router.put('/api/v1/user/profile', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
await query('UPDATE users SET nickname = COALESCE(:nickname, nickname), avatar = COALESCE(:avatar, avatar), gender = COALESCE(:gender, gender) WHERE user_id = :user_id', {
|
||||
user_id: user.user_id,
|
||||
nickname: ctx.body.nickname || null,
|
||||
avatar: ctx.body.avatar || ctx.body.avatar_url || null,
|
||||
gender: ctx.body.gender === undefined ? null : ctx.body.gender
|
||||
})
|
||||
await writeLog({ user_id: user.user_id, action: 'user_update', detail: '更新用户资料', ip: ctx.ip })
|
||||
return ok({ message: 'success' })
|
||||
})
|
||||
router.post('/user/phone', requireUser, wrap(async (req, res) => {
|
||||
const code = String(req.body.code || '').trim()
|
||||
if (!code) return res.json(fail(2001, 'phone code required'))
|
||||
const phoneInfo = await getPhoneNumber(code)
|
||||
if (!phoneInfo || !phoneInfo.phoneNumber) return res.json(fail(2001, 'phone authorization failed'))
|
||||
await userDao.updatePhone(req.user.user_id, phoneInfo.phoneNumber)
|
||||
await logDao.write({ user_id: req.user.user_id, action: 'user_phone_bind', detail: '授权手机号', ip: req.ip })
|
||||
res.json(ok({ phone: phoneInfo.phoneNumber, pure_phone_number: phoneInfo.purePhoneNumber || '', country_code: phoneInfo.countryCode || '' }))
|
||||
}))
|
||||
|
||||
router.post('/api/v1/user/phone', async ctx => {
|
||||
const user = await requireUser(ctx)
|
||||
if (!user) return fail(1001, 'invalid_token')
|
||||
const code = String(ctx.body.code || '').trim()
|
||||
if (!code) return fail(2001, 'phone code required')
|
||||
const phoneInfo = await getPhoneNumber(code)
|
||||
if (!phoneInfo || !phoneInfo.phoneNumber) return fail(2001, 'phone authorization failed')
|
||||
await query('UPDATE users SET phone = :phone WHERE user_id = :user_id', {
|
||||
user_id: user.user_id,
|
||||
phone: phoneInfo.phoneNumber
|
||||
})
|
||||
await writeLog({ user_id: user.user_id, action: 'user_phone_bind', detail: '授权手机号', ip: ctx.ip })
|
||||
return ok({ phone: phoneInfo.phoneNumber, pure_phone_number: phoneInfo.purePhoneNumber || '', country_code: phoneInfo.countryCode || '' })
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = register
|
||||
module.exports = router
|
||||
|
||||
在新工单中引用
屏蔽一个用户