feat: init project with miniprogram, cloud functions and admin console

这个提交包含在:
Guoguo
2026-04-22 21:24:20 +08:00
当前提交 a84e37a6e2
修改 106 个文件,包含 5902 行新增0 行删除
+62
查看文件
@@ -0,0 +1,62 @@
var jwt = require('jsonwebtoken')
var JWT_SECRET = process.env.JWT_SECRET || 'hox_jwt_secret_change_in_production'
var JWT_EXPIRES_IN = '7d'
function generateToken(userId, openid) {
return jwt.sign(
{ user_id: userId, openid: openid },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (e) {
return null
}
}
function generateAdminToken(adminId, username, role) {
return jwt.sign(
{ admin_id: adminId, username: username, role: role, type: 'admin' },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
}
function extractToken(event) {
var header = event.headers || {}
var auth = header['Authorization'] || header['authorization'] || header['authorization'] || ''
if (auth.startsWith('Bearer ')) {
return auth.substring(7)
}
return null
}
function extractUser(event) {
var token = extractToken(event)
if (!token) return null
var decoded = verifyToken(token)
if (!decoded || decoded.type === 'admin') return null
return decoded
}
function extractAdmin(event) {
var token = extractToken(event)
if (!token) return null
var decoded = verifyToken(token)
if (!decoded || decoded.type !== 'admin') return null
return decoded
}
module.exports = {
generateToken: generateToken,
verifyToken: verifyToken,
generateAdminToken: generateAdminToken,
extractToken: extractToken,
extractUser: extractUser,
extractAdmin: extractAdmin
}
+45
查看文件
@@ -0,0 +1,45 @@
var mysql = require('mysql2/promise')
var pool = null
function getPool() {
if (pool) return pool
pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'hox',
waitForConnections: true,
connectionLimit: 10,
charset: 'utf8mb4'
})
return pool
}
async function query(sql, params) {
var p = getPool()
var [rows] = await p.execute(sql, params)
return rows
}
async function insert(sql, params) {
var p = getPool()
var [result] = await p.execute(sql, params)
return result
}
async function update(sql, params) {
var p = getPool()
var [result] = await p.execute(sql, params)
return result
}
module.exports = {
query: query,
insert: insert,
update: update,
getPool: getPool
}
+27
查看文件
@@ -0,0 +1,27 @@
var db = require('./db')
async function writeLog(options) {
try {
await db.insert(
'INSERT INTO operation_logs (operator_type, operator_id, target_type, target_id, action, before_snapshot, after_snapshot, ip_address, user_agent, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
options.operator_type || 2,
options.operator_id || null,
options.target_type || '',
options.target_id || null,
options.action || '',
options.before_snapshot ? JSON.stringify(options.before_snapshot) : null,
options.after_snapshot ? JSON.stringify(options.after_snapshot) : null,
options.ip_address || null,
options.user_agent || null,
options.remark || null
]
)
} catch (e) {
console.error('writeLog failed:', e.message)
}
}
module.exports = {
writeLog: writeLog
}
+47
查看文件
@@ -0,0 +1,47 @@
var RESPONSE = {
success: function (data, message) {
return {
code: 0,
message: message || 'success',
data: data || null
}
},
error: function (code, message) {
return {
code: code || -1,
message: message || 'error',
data: null
}
},
paginate: function (list, total, page, pageSize) {
return {
code: 0,
message: 'success',
data: {
total: total,
page: page,
page_size: pageSize,
records: list
}
}
}
}
var ERROR_CODES = {
INVALID_TOKEN: { code: 1001, message: 'invalid_token' },
TOKEN_EXPIRED: { code: 1002, message: 'token_expired' },
PERMISSION_DENIED: { code: 1003, message: 'permission_denied' },
USER_NOT_FOUND: { code: 1004, message: 'user_not_found' },
DEVICE_NOT_FOUND: { code: 1005, message: 'device_not_found' },
DEVICE_NOT_BOUND: { code: 1006, message: 'device_not_bound' },
SUBSCRIPTION_REQUIRED: { code: 1007, message: 'subscription_required' },
SUBSCRIPTION_EXPIRED: { code: 1008, message: 'subscription_expired' },
PARAM_ERROR: { code: 2001, message: 'param_error' },
NETWORK_ERROR: { code: 2002, message: 'network_error' },
SERVER_ERROR: { code: 3001, message: 'server_error' }
}
module.exports = {
RESPONSE: RESPONSE,
ERROR_CODES: ERROR_CODES
}
+308
查看文件
@@ -0,0 +1,308 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
var bcrypt = require('bcryptjs')
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var pathParams = event.pathParameters || {}
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/admin/login') {
return await adminLogin(body, event)
}
var admin = auth.extractAdmin(event)
if (!admin) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (method === 'GET' && path === '/api/v1/admin/dashboard') {
return await getDashboard()
}
if (method === 'GET' && path === '/api/v1/admin/devices' && !pathParams.device_id) {
return await listDevices(body)
}
if (method === 'GET' && pathParams.device_id && !path.match(/\/command$/)) {
return await getDeviceDetail(pathParams.device_id)
}
if (method === 'POST' && path.match(/\/command$/)) {
return await sendDeviceCommand(pathParams.device_id, body)
}
if (method === 'GET' && path === '/api/v1/admin/users' && !pathParams.user_id) {
return await listUsers(body)
}
if (method === 'GET' && pathParams.user_id && path.indexOf('/admin/users/') !== -1) {
return await getUserDetail(pathParams.user_id)
}
if (method === 'GET' && path === '/api/v1/admin/subscriptions') {
return await listSubscriptions(body)
}
if (method === 'POST' && path === '/api/v1/admin/subscriptions') {
return await createSubscription(event, body)
}
if (method === 'GET' && path === '/api/v1/admin/logs') {
return await getLogs(body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('admin error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function adminLogin(body, event) {
if (!body.username || !body.password) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing username or password')
}
var admins = await db.query(
'SELECT id, username, password_hash, role, real_name FROM admins WHERE username = ? AND status = 1 AND deleted_at IS NULL LIMIT 1',
[body.username]
)
if (admins.length === 0) {
return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'invalid credentials')
}
var admin = admins[0]
var valid = bcrypt.compareSync(body.password, admin.password_hash)
if (!valid) {
return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'invalid credentials')
}
await db.update(
'UPDATE admins SET last_login_at = NOW() WHERE id = ?',
[admin.id]
)
var token = auth.generateAdminToken(admin.id, admin.username, admin.role)
await log.writeLog({
operator_type: 1,
operator_id: admin.id,
target_type: 'admin',
target_id: String(admin.id),
action: 'login',
ip_address: getClientIp(event)
})
return RESPONSE.success({
token: token,
admin_id: String(admin.id),
username: admin.username,
real_name: admin.real_name,
role: admin.role
})
}
async function getDashboard() {
var userCount = await db.query('SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL')
var deviceCount = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE deleted_at IS NULL')
var activeDevices = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE status = 2 AND deleted_at IS NULL')
var subCount = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions WHERE status = 1 AND expired_at > NOW()')
var trialCount = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions WHERE type = 1')
var treatmentCount = await db.query('SELECT COUNT(*) AS cnt FROM treatment_records')
var todayTreatments = await db.query(
"SELECT COUNT(*) AS cnt FROM treatment_records WHERE DATE(created_at) = CURDATE()"
)
return RESPONSE.success({
user_count: userCount[0].cnt,
device_count: deviceCount[0].cnt,
active_device_count: activeDevices[0].cnt,
subscription_count: subCount[0].cnt,
trial_count: trialCount[0].cnt,
treatment_count: treatmentCount[0].cnt,
today_treatment_count: todayTreatments[0].cnt
})
}
async function listDevices(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var total = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE deleted_at IS NULL')
var devices = await db.query(
'SELECT d.id, d.device_id, d.device_name, d.device_type, d.hw_version, d.fw_version, d.status, d.activated_at, d.created_at, ' +
'u.nickname AS bound_user, b.bound_at ' +
'FROM devices d ' +
'LEFT JOIN bindings b ON d.id = b.device_id AND b.status = 1 ' +
'LEFT JOIN users u ON b.user_id = u.id AND u.deleted_at IS NULL ' +
'WHERE d.deleted_at IS NULL ' +
'ORDER BY d.created_at DESC LIMIT ? OFFSET ?',
[pageSize, offset]
)
return RESPONSE.paginate(devices, total[0].cnt, page, pageSize)
}
async function getDeviceDetail(deviceId) {
var devices = await db.query(
'SELECT * FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[deviceId]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var bindings = await db.query(
'SELECT b.*, u.nickname, u.avatar_url FROM bindings b JOIN users u ON b.user_id = u.id WHERE b.device_id = ? ORDER BY b.bound_at DESC',
[devices[0].id]
)
return RESPONSE.success(Object.assign({}, devices[0], { binding_history: bindings }))
}
async function sendDeviceCommand(deviceId, body) {
var devices = await db.query(
'SELECT id, device_id, product_id, device_name FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[deviceId]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
return RESPONSE.success({ message: 'command queued', device_id: deviceId, command: body })
}
async function listUsers(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var total = await db.query('SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL')
var users = await db.query(
'SELECT u.id AS user_id, u.nickname, u.avatar_url, u.phone, u.status, u.created_at, ' +
'COUNT(DISTINCT b.id) AS device_count, ' +
'MAX(s.expired_at) AS subscription_expire ' +
'FROM users u ' +
'LEFT JOIN bindings b ON u.id = b.user_id AND b.status = 1 ' +
'LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status = 1 AND s.expired_at > NOW() ' +
'WHERE u.deleted_at IS NULL ' +
'GROUP BY u.id ' +
'ORDER BY u.created_at DESC LIMIT ? OFFSET ?',
[pageSize, offset]
)
return RESPONSE.paginate(users, total[0].cnt, page, pageSize)
}
async function getUserDetail(userId) {
var users = await db.query(
'SELECT id AS user_id, openid, nickname, avatar_url, phone, status, created_at FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1',
[userId]
)
if (users.length === 0) {
return RESPONSE.error(ERROR_CODES.USER_NOT_FOUND.code, ERROR_CODES.USER_NOT_FOUND.message)
}
var bindings = await db.query(
'SELECT b.bound_at, d.device_id, d.device_name, d.fw_version FROM bindings b JOIN devices d ON b.device_id = d.id WHERE b.user_id = ? AND b.status = 1',
[userId]
)
var subs = await db.query(
'SELECT type, plan, status, started_at, expired_at, source FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 5',
[userId]
)
var treatments = await db.query(
'SELECT session_id, regions, total_duration_ms, mode, started_at, ended_at FROM treatment_records WHERE user_id = ? ORDER BY started_at DESC LIMIT 5',
[userId]
)
return RESPONSE.success(Object.assign({}, users[0], {
devices: bindings,
subscriptions: subs,
recent_treatments: treatments
}))
}
async function listSubscriptions(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var total = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions')
var subs = await db.query(
'SELECT s.id, s.user_id, u.nickname, s.type, s.plan, s.status, s.trial_used, s.started_at, s.expired_at, s.source ' +
'FROM subscriptions s ' +
'JOIN users u ON s.user_id = u.id ' +
'ORDER BY s.created_at DESC LIMIT ? OFFSET ?',
[pageSize, offset]
)
return RESPONSE.paginate(subs, total[0].cnt, page, pageSize)
}
async function createSubscription(event, body) {
if (!body.user_id || !body.plan || !body.days) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing user_id, plan or days')
}
await db.insert(
'INSERT INTO subscriptions (user_id, type, plan, status, started_at, expired_at, source) VALUES (?, 2, ?, 1, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY), ?)',
[body.user_id, body.plan, body.days, body.source || 'manual_create']
)
await log.writeLog({
operator_type: 1,
operator_id: event.admin_id,
target_type: 'subscription',
target_id: String(body.user_id),
action: 'create',
remark: 'plan=' + body.plan + ',days=' + body.days
})
return RESPONSE.success({ message: 'success' })
}
async function getLogs(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var where = '1=1'
var params = []
if (body.type) {
where += ' AND target_type = ?'
params.push(body.type)
}
if (body.device_id) {
where += ' AND target_id = ?'
params.push(body.device_id)
}
if (body.start_time) {
where += ' AND created_at >= ?'
params.push(body.start_time)
}
if (body.end_time) {
where += ' AND created_at <= ?'
params.push(body.end_time)
}
var total = await db.query('SELECT COUNT(*) AS cnt FROM operation_logs WHERE ' + where, params)
var logs = await db.query(
'SELECT * FROM operation_logs WHERE ' + where + ' ORDER BY created_at DESC LIMIT ? OFFSET ?',
params.concat([pageSize, offset])
)
return RESPONSE.paginate(logs, total[0].cnt, page, pageSize)
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
function getClientIp(event) {
var headers = event.headers || {}
return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || ''
}
+136
查看文件
@@ -0,0 +1,136 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/auth/login') {
return await wxLogin(body, event)
}
if (method === 'POST' && path === '/api/v1/auth/refresh') {
return await refreshToken(body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('auth error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function wxLogin(body, event) {
if (!body.code) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing code')
}
var wxResult = await requestWxSession(body.code)
if (!wxResult || !wxResult.openid) {
return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, 'wechat login failed')
}
var users = await db.query(
'SELECT id, openid, nickname, avatar_url, phone, status FROM users WHERE openid = ? AND deleted_at IS NULL LIMIT 1',
[wxResult.openid]
)
var userId
var isNew = false
if (users.length === 0) {
var result = await db.insert(
'INSERT INTO users (openid, nickname, avatar_url, status) VALUES (?, ?, ?, 1)',
[wxResult.openid, body.nickname || null, body.avatar_url || null]
)
userId = result.insertId
isNew = true
} else {
userId = users[0].id
if (body.nickname || body.avatar_url) {
await db.update(
'UPDATE users SET nickname = COALESCE(?, nickname), avatar_url = COALESCE(?, avatar_url) WHERE id = ?',
[body.nickname || null, body.avatar_url || null, userId]
)
}
}
var token = auth.generateToken(userId, wxResult.openid)
await log.writeLog({
operator_type: 2,
operator_id: userId,
target_type: 'user',
target_id: String(userId),
action: isNew ? 'create' : 'login',
ip_address: getClientIp(event)
})
return RESPONSE.success({
token: token,
user_id: String(userId),
user_info: {
id: userId,
nickname: body.nickname || null,
avatar_url: body.avatar_url || null
},
expires_in: 604800
})
}
async function refreshToken(body) {
if (!body.refresh_token) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing refresh_token')
}
var decoded = auth.verifyToken(body.refresh_token)
if (!decoded || decoded.type === 'admin') {
return RESPONSE.error(ERROR_CODES.TOKEN_EXPIRED.code, 'invalid refresh_token')
}
var token = auth.generateToken(decoded.user_id, decoded.openid)
return RESPONSE.success({
token: token,
expires_in: 604800
})
}
async function requestWxSession(code) {
var appId = process.env.WX_APPID || ''
var appSecret = process.env.WX_APP_SECRET || ''
var url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' + appId +
'&secret=' + appSecret +
'&js_code=' + code +
'&grant_type=authorization_code'
var https = require('https')
return new Promise(function (resolve) {
https.get(url, function (res) {
var data = ''
res.on('data', function (chunk) { data += chunk })
res.on('end', function () {
try {
resolve(JSON.parse(data))
} catch (e) {
resolve(null)
}
})
}).on('error', function () { resolve(null) })
})
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
function getClientIp(event) {
var headers = event.headers || {}
return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || headers['x-real-ip'] || ''
}
+195
查看文件
@@ -0,0 +1,195 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
var uuid = require('uuid')
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var pathParams = event.pathParameters || {}
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/device/bind') {
return await bindDevice(event, body)
}
if (method === 'POST' && path === '/api/v1/device/unbind') {
return await unbindDevice(event, body)
}
if (method === 'GET' && path === '/api/v1/device/list') {
return await listDevices(event)
}
if (method === 'GET' && pathParams.device_id) {
return await getDeviceDetail(event, pathParams.device_id)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('device error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function bindDevice(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.device_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing device_id')
}
var devices = await db.query(
'SELECT id, device_id, device_name, product_id, status FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[body.device_id]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var device = devices[0]
var existing = await db.query(
'SELECT id FROM bindings WHERE device_id = ? AND status = 1',
[device.id]
)
if (existing.length > 0) {
var existingUser = await db.query('SELECT id FROM users WHERE id = ? AND deleted_at IS NULL', [existing[0].user_id])
if (existingUser.length > 0 && String(existing[0].user_id) !== String(user.user_id)) {
return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'device already bound')
}
if (existingUser.length > 0 && String(existing[0].user_id) === String(user.user_id)) {
var bindToken = uuid.v4().replace(/-/g, '').substring(0, 16)
await db.update(
'UPDATE bindings SET bind_token = ?, updated_at = NOW() WHERE id = ?',
[bindToken, existing[0].id]
)
return RESPONSE.success({ bind_token: bindToken, bind_expires: 300 })
}
}
var bindToken = uuid.v4().replace(/-/g, '').substring(0, 16)
await db.insert(
'INSERT INTO bindings (user_id, device_id, bind_token, status, bound_at) VALUES (?, ?, ?, 1, NOW())',
[user.user_id, device.id, bindToken]
)
await db.update(
'UPDATE devices SET status = 2, activated_at = COALESCE(activated_at, NOW()) WHERE id = ?',
[device.id]
)
await grantTrialIfNeeded(user.user_id)
await log.writeLog({
operator_type: 2,
operator_id: user.user_id,
target_type: 'device',
target_id: device.device_id,
action: 'bind',
ip_address: getClientIp(event)
})
return RESPONSE.success({ bind_token: bindToken, bind_expires: 300 })
}
async function unbindDevice(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.device_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing device_id')
}
var devices = await db.query(
'SELECT id, device_id FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[body.device_id]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var result = await db.update(
'UPDATE bindings SET status = 2, unbound_at = NOW(), updated_at = NOW() WHERE user_id = ? AND device_id = ? AND status = 1',
[user.user_id, devices[0].id]
)
if (result.affectedRows === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_BOUND.code, ERROR_CODES.DEVICE_NOT_BOUND.message)
}
await log.writeLog({
operator_type: 2,
operator_id: user.user_id,
target_type: 'device',
target_id: body.device_id,
action: 'unbind',
ip_address: getClientIp(event)
})
return RESPONSE.success({ message: 'success' })
}
async function listDevices(event) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var bindings = await db.query(
'SELECT b.bound_at AS bind_time, b.bind_token, d.device_id, d.device_name, d.fw_version AS firmware_version, d.hw_version, d.status ' +
'FROM bindings b ' +
'JOIN devices d ON b.device_id = d.id ' +
'WHERE b.user_id = ? AND b.status = 1 AND d.deleted_at IS NULL ' +
'ORDER BY b.bound_at DESC',
[user.user_id]
)
return RESPONSE.success({ devices: bindings })
}
async function getDeviceDetail(event, deviceId) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var bindings = await db.query(
'SELECT b.id, b.bound_at AS bind_time FROM bindings b ' +
'JOIN devices d ON b.device_id = d.id ' +
'WHERE b.user_id = ? AND b.status = 1 AND d.device_id = ? AND d.deleted_at IS NULL LIMIT 1',
[user.user_id, deviceId]
)
if (bindings.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var devices = await db.query(
'SELECT device_id, device_name, device_type, hw_version, fw_version AS firmware_version, status, activated_at FROM devices WHERE device_id = ? LIMIT 1',
[deviceId]
)
return RESPONSE.success(Object.assign({}, devices[0], { bind_time: bindings[0].bind_time }))
}
async function grantTrialIfNeeded(userId) {
var existing = await db.query(
'SELECT id FROM subscriptions WHERE user_id = ? AND type = 1 LIMIT 1',
[userId]
)
if (existing.length > 0) return
await db.insert(
'INSERT INTO subscriptions (user_id, type, plan, status, trial_used, started_at, expired_at, source) ' +
'VALUES (?, 1, NULL, 1, 1, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY), ?)',
[userId, 'trial_grant']
)
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
function getClientIp(event) {
var headers = event.headers || {}
return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || ''
}
+136
查看文件
@@ -0,0 +1,136 @@
var db = require('../common/db')
var RESPONSE = require('../common/response').RESPONSE
var ERROR_CODES = require('../common/response').ERROR_CODES
exports.main_handler = async function (event, context) {
try {
var token = extractToken(event)
if (!token) {
return RESPONSE.error(ERROR_CODES.UNAUTHORIZED.code, ERROR_CODES.UNAUTHORIZED.message)
}
var user = await verifyToken(token)
if (!user) {
return RESPONSE.error(ERROR_CODES.TOKEN_EXPIRED.code, ERROR_CODES.TOKEN_EXPIRED.message)
}
var body = parseBody(event)
switch (event.path || event.action) {
case '/record/sync':
return await syncRecord(user, body)
case '/record/list':
return await listRecords(user, body)
case '/record/detail':
return await getRecordDetail(user, body)
default:
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '未知操作')
}
} catch (err) {
return RESPONSE.error(ERROR_CODES.INTERNAL_ERROR.code, err.message)
}
}
async function syncRecord(user, body) {
if (!body.device_id || !body.started_at || !body.ended_at) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '参数不完整')
}
var dbConfig = getDbConfig()
var sessionResult = await db.query(
'INSERT INTO sessions (user_id, device_id, status, started_at, ended_at, created_at, updated_at) ' +
'VALUES (?, ?, 2, ?, ?, NOW(), NOW())',
[user.id, body.device_id, body.started_at, body.ended_at],
dbConfig
)
var sessionId = sessionResult.insertId
await db.query(
'INSERT INTO treatment_records ' +
'(session_id, user_id, device_id, duration_seconds, mode, result_summary, sync_status, synced_at, started_at, ended_at, created_at, updated_at) ' +
'VALUES (?, ?, ?, ?, ?, ?, 2, NOW(), ?, ?, NOW(), NOW())',
[
sessionId,
user.id,
body.device_id,
body.duration_seconds || null,
body.mode || null,
body.result_summary || null,
body.started_at,
body.ended_at
],
dbConfig
)
return RESPONSE.success({ session_id: sessionId })
}
async function listRecords(user, body) {
var dbConfig = getDbConfig()
var limit = parseInt(body.limit) || 20
var offset = parseInt(body.offset) || 0
var records = await db.query(
'SELECT * FROM treatment_records WHERE user_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?',
[user.id, limit, offset],
dbConfig
)
return RESPONSE.success({ list: records })
}
async function getRecordDetail(user, body) {
if (!body.record_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '缺少记录ID')
}
var dbConfig = getDbConfig()
var records = await db.query(
'SELECT * FROM treatment_records WHERE id = ? AND user_id = ? LIMIT 1',
[body.record_id, user.id],
dbConfig
)
if (records.length === 0) {
return RESPONSE.error(ERROR_CODES.NOT_FOUND.code, ERROR_CODES.NOT_FOUND.message)
}
return RESPONSE.success(records[0])
}
function extractToken(event) {
var header = event.headers || {}
var auth = header['Authorization'] || header['authorization'] || ''
if (auth.startsWith('Bearer ')) {
return auth.substring(7)
}
return null
}
async function verifyToken(token) {
return { id: 1, openid: 'placeholder' }
}
function parseBody(event) {
if (event.body) {
try {
return JSON.parse(event.body)
} catch (e) {
return {}
}
}
return event.queryString || {}
}
function getDbConfig() {
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'hox'
}
}
+138
查看文件
@@ -0,0 +1,138 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'GET' && path === '/api/v1/subscription') {
return await getSubscription(event)
}
if (method === 'POST' && path === '/api/v1/subscription/purchase') {
return await purchaseSubscription(event, body)
}
if (method === 'POST' && path === '/api/v1/subscription/verify') {
return await verifyPayment(event, body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('subscription error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function getSubscription(event) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var subs = await db.query(
'SELECT id, type, plan, status, trial_used, started_at AS start_time, expired_at AS expire_time ' +
'FROM subscriptions ' +
'WHERE user_id = ? AND status = 1 AND expired_at > NOW() ' +
'ORDER BY expired_at DESC LIMIT 1',
[user.user_id]
)
if (subs.length === 0) {
return RESPONSE.success({
status: 0,
plan: null,
start_time: null,
expire_time: null,
remaining_days: 0
})
}
var sub = subs[0]
var remainingMs = new Date(sub.expire_time) - new Date()
var remainingDays = Math.ceil(remainingMs / 86400000)
return RESPONSE.success({
status: sub.type === 1 ? 1 : 2,
plan: sub.plan,
start_time: sub.start_time,
expire_time: sub.expire_time,
remaining_days: remainingDays
})
}
async function purchaseSubscription(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.plan) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing plan')
}
var planDays = { monthly: 30, quarterly: 90, yearly: 365 }
var days = planDays[body.plan] || 30
var orderId = 'ORD' + Date.now() + Math.random().toString(36).substring(2, 8)
return RESPONSE.success({
order_id: orderId,
payment_params: {
plan: body.plan,
days: days,
amount: 0,
channel: body.payment_method || 'wechat'
}
})
}
async function verifyPayment(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.order_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing order_id')
}
var planDays = { monthly: 30, quarterly: 90, yearly: 365 }
var currentSub = await db.query(
'SELECT id, expired_at FROM subscriptions WHERE user_id = ? AND status = 1 AND expired_at > NOW() ORDER BY expired_at DESC LIMIT 1',
[user.user_id]
)
var startDate = new Date()
if (currentSub.length > 0 && new Date(currentSub[0].expired_at) > startDate) {
startDate = new Date(currentSub[0].expired_at)
}
var plan = body.plan || 'monthly'
var days = planDays[plan] || 30
var expiredAt = new Date(startDate.getTime() + days * 86400000)
await db.insert(
'INSERT INTO subscriptions (user_id, type, plan, status, started_at, expired_at, source, source_id) VALUES (?, 2, ?, 1, ?, ?, ?, ?)',
[user.user_id, plan, startDate.toISOString().slice(0, 19).replace('T', ' '), expiredAt.toISOString().slice(0, 19).replace('T', ' '), 'purchase', body.order_id]
)
await log.writeLog({
operator_type: 2,
operator_id: user.user_id,
target_type: 'subscription',
target_id: body.order_id,
action: 'purchase',
remark: 'plan=' + plan + ',days=' + days
})
return RESPONSE.success({
status: 2,
plan: plan,
expire_time: expiredAt.toISOString().slice(0, 19).replace('T', ' ')
})
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
+130
查看文件
@@ -0,0 +1,130 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/treatment/sync') {
return await syncRecord(event, body)
}
if (method === 'GET' && path === '/api/v1/treatment/history') {
return await getHistory(event, body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('treatment error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function syncRecord(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.session_id || !body.device_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing session_id or device_id')
}
var devices = await db.query(
'SELECT id FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[body.device_id]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var devicePkId = devices[0].id
var existingSession = await db.query(
'SELECT id FROM sessions WHERE session_id = ? LIMIT 1',
[body.session_id]
)
if (existingSession.length === 0) {
await db.insert(
'INSERT INTO sessions (session_id, user_id, device_id, status, started_at, ended_at) VALUES (?, ?, ?, 2, ?, ?)',
[
body.session_id,
user.user_id,
devicePkId,
body.start_time ? new Date(body.start_time).toISOString().slice(0, 19).replace('T', ' ') : null,
body.end_time ? new Date(body.end_time).toISOString().slice(0, 19).replace('T', ' ') : null
]
)
}
var existingRecord = await db.query(
'SELECT id FROM treatment_records WHERE session_id = ? LIMIT 1',
[body.session_id]
)
if (existingRecord.length > 0) {
return RESPONSE.success({ record_id: existingRecord[0].id })
}
var result = await db.insert(
'INSERT INTO treatment_records (session_id, user_id, device_id, regions, total_duration_ms, mode, avg_pd, sync_status, synced_at, started_at, ended_at) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, 2, NOW(), ?, ?)',
[
body.session_id,
user.user_id,
devicePkId,
body.regions || null,
body.total_duration_ms || null,
body.mode !== undefined ? body.mode : null,
body.avg_pd || null,
body.start_time ? new Date(body.start_time).toISOString().slice(0, 19).replace('T', ' ') : null,
body.end_time ? new Date(body.end_time).toISOString().slice(0, 19).replace('T', ' ') : null
]
)
if (body.pd_data && Array.isArray(body.pd_data)) {
for (var i = 0; i < body.pd_data.length; i++) {
var pd = body.pd_data[i]
await db.insert(
'INSERT INTO pd_data (session_id, device_id, region_name, pd_value, recorded_at) VALUES (?, ?, ?, ?, ?)',
[body.session_id, devicePkId, pd.name, pd.pd || null, new Date().toISOString().slice(0, 19).replace('T', ' ')]
)
}
}
return RESPONSE.success({ record_id: result.insertId })
}
async function getHistory(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var totalResult = await db.query(
'SELECT COUNT(*) AS cnt FROM treatment_records WHERE user_id = ?',
[user.user_id]
)
var total = totalResult[0].cnt
var records = await db.query(
'SELECT tr.id AS record_id, tr.session_id, tr.regions, tr.total_duration_ms, tr.mode, tr.avg_pd, tr.started_at AS start_time, tr.ended_at AS end_time, d.device_id, d.device_name ' +
'FROM treatment_records tr ' +
'JOIN devices d ON tr.device_id = d.id ' +
'WHERE tr.user_id = ? ' +
'ORDER BY tr.started_at DESC LIMIT ? OFFSET ?',
[user.user_id, pageSize, offset]
)
return RESPONSE.paginate(records, total, page, pageSize)
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
+74
查看文件
@@ -0,0 +1,74 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'GET' && path === '/api/v1/user/profile') {
return await getProfile(event)
}
if (method === 'PUT' && path === '/api/v1/user/profile') {
return await updateProfile(event, body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('user error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function getProfile(event) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var users = await db.query(
'SELECT id AS user_id, nickname, avatar_url AS avatar, phone, status FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1',
[user.user_id]
)
if (users.length === 0) {
return RESPONSE.error(ERROR_CODES.USER_NOT_FOUND.code, ERROR_CODES.USER_NOT_FOUND.message)
}
var profile = users[0]
var bindings = await db.query(
'SELECT COUNT(*) AS cnt FROM bindings WHERE user_id = ? AND status = 1',
[user.user_id]
)
var bindTime = await db.query(
'SELECT bound_at AS bind_time FROM bindings WHERE user_id = ? AND status = 1 ORDER BY bound_at ASC LIMIT 1',
[user.user_id]
)
profile.device_count = bindings[0].cnt
profile.bind_time = bindTime.length > 0 ? bindTime[0].bind_time : null
return RESPONSE.success(profile)
}
async function updateProfile(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (body.nickname) {
await db.update(
'UPDATE users SET nickname = ? WHERE id = ?',
[body.nickname, user.user_id]
)
}
return RESPONSE.success({ message: 'success' })
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
+14
查看文件
@@ -0,0 +1,14 @@
{
"name": "hox-cloud-functions",
"version": "1.0.0",
"description": "光子美容仪云函数服务",
"main": "index.js",
"dependencies": {
"tencentcloud-sdk-nodejs": "^4.0.0",
"mysql2": "^3.0.0",
"ioredis": "^5.0.0",
"jsonwebtoken": "^9.0.0",
"bcryptjs": "^2.4.0",
"uuid": "^9.0.0"
}
}