refactor: migrate to Tencent Cloud backend

这个提交包含在:
Guoguo
2026-04-28 22:56:47 +08:00
父节点 267c75718b
当前提交 444c91c0b0
修改 102 个文件,包含 17927 行新增1997 行删除
+24
查看文件
@@ -0,0 +1,24 @@
NODE_ENV=development
PORT=3000
TENCENT_SECRET_ID=
TENCENT_SECRET_KEY=
TENCENT_REGION=ap-guangzhou
DB_HOST=
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=jw_beauty
COS_BUCKET=jw-bucket-1426323813
COS_REGION=ap-guangzhou
WECHAT_APPID=
WECHAT_SECRET=
JWT_SECRET=replace-with-a-long-random-secret
ADMIN_JWT_SECRET=replace-with-a-different-long-random-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
+30
查看文件
@@ -0,0 +1,30 @@
NODE_ENV=production
PORT=9000
# Tencent Cloud API credentials. Use SCF environment variables or CAM role where possible.
TENCENT_SECRET_ID=
TENCENT_SECRET_KEY=
TENCENT_REGION=ap-guangzhou
# TencentDB for MySQL. In SCF, prefer the Tencent Cloud private network endpoint.
DB_HOST=
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=jw_beauty
# COS bucket for firmware and static assets.
COS_BUCKET=jw-bucket-1426323813
COS_REGION=ap-guangzhou
# WeChat Mini Program credentials.
WECHAT_APPID=
WECHAT_SECRET=
# Replace with long random strings before deployment.
JWT_SECRET=
ADMIN_JWT_SECRET=
# Temporary bootstrap admin. Change after first login.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
+121
查看文件
@@ -0,0 +1,121 @@
# Hox 腾讯云函数后端
本目录是 Hox 项目的腾讯云后端骨架,目标架构为:腾讯云函数 SCF Node.js + 腾讯云数据库 MySQL + 腾讯云 COS。
## 配置
本地配置文件为 `.env`,不会提交到 Git。提交用模板为 `.env.example`
当前已按项目默认值预置:
| 配置 | 值 |
| --- | --- |
| COS Bucket | `jw-bucket-1426323813` |
| 地域 | `ap-guangzhou` |
| 数据库 | `jw_beauty` |
| 数据库用户 | `root` |
| 临时后台账号 | `admin` |
| 临时后台密码 | `admin` |
你需要补充 `.env` 中的真实值:
- `TENCENT_SECRET_ID`
- `TENCENT_SECRET_KEY`
- `DB_HOST`
- `DB_PASSWORD`
- `WECHAT_APPID`
- `WECHAT_SECRET`
- `JWT_SECRET`
- `ADMIN_JWT_SECRET`
## 本地运行
```bash
cd server
npm install
npm run db:init
npm start
```
健康检查:
```bash
curl http://localhost:3000/health
```
## SCF 入口
腾讯云函数入口:
```text
index.main_handler
```
HTTP 触发器或函数 URL 需要透传:
- HTTP method
- path
- headers
- queryStringParameters
- body
## 已实现接口
小程序接口:
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `GET /api/v1/user/profile`
- `PUT /api/v1/user/profile`
- `POST /api/v1/user/avatar/upload-url`
- `POST /api/v1/user/phone`
- `POST /api/v1/device/bind`
- `POST /api/v1/device/unbind`
- `GET /api/v1/device/list`
- `GET /api/v1/device/:device_id`
- `GET /api/v1/device/command/pending`
- `POST /api/v1/device/event`
- `GET /api/v1/subscription`
- `POST /api/v1/subscription/purchase`
- `POST /api/v1/subscription/verify`
- `GET /api/v1/treatment/history`
- `POST /api/v1/treatment/sync`
- `GET /api/v1/firmware/latest`
管理后台接口:
- `POST /api/v1/admin/login`
- `GET /api/v1/admin/dashboard`
- `GET /api/v1/admin/devices`
- `GET /api/v1/admin/devices/:device_id`
- `POST /api/v1/admin/devices/:device_id/command`
- `POST /api/v1/admin/devices/:device_id/unbind`
- `GET /api/v1/admin/users`
- `GET /api/v1/admin/users/:user_id`
- `GET /api/v1/admin/subscriptions`
- `POST /api/v1/admin/subscriptions`
- `GET /api/v1/admin/records`
- `GET /api/v1/admin/logs`
- `GET /api/v1/admin/settings`
- `POST /api/v1/admin/settings`
- `GET /api/v1/admin/devices/:device_id/commands`
- `GET /api/v1/admin/firmware`
- `POST /api/v1/admin/firmware`
- `POST /api/v1/admin/firmware/:firmware_id/status`
## 数据库
表结构在:
```text
sql/schema.sql
```
初始化脚本会创建表并插入临时管理员账号。
## 重要说明
- 当前微信支付只保留接口骨架,未接入真实微信支付回调。
- `/api/v1/device/command/pending` 已接入 `device_commands`,小程序拉取后执行 BLE 指令并回传结果。
- `/api/v1/firmware/latest` 会读取 `firmware_files` 并生成 COS 签名 URL,后台可通过固件接口登记 COS 对象 key。
- 生产环境必须替换 `admin/admin` 和所有默认 secret。
+1
查看文件
@@ -0,0 +1 @@
module.exports = require('./src/index')
+1089
查看文件
文件差异内容过多而无法显示 加载差异
+18
查看文件
@@ -0,0 +1,18 @@
{
"name": "hox-scf-api",
"version": "1.0.0",
"private": true,
"description": "Tencent Cloud SCF Node.js API for Hox beauty device",
"main": "src/index.js",
"scripts": {
"start": "node scripts/local-server.js",
"db:init": "node scripts/init-db.js"
},
"dependencies": {
"cos-nodejs-sdk-v5": "^2.14.7",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
},
"devDependencies": {}
}
可执行文件
+4
查看文件
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
export PORT=9000
node scripts/local-server.js
+41
查看文件
@@ -0,0 +1,41 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') })
const fs = require('fs')
const path = require('path')
const mysql = require('mysql2/promise')
const config = require('../src/config')
const { hashPassword, randomHex } = require('../src/lib/auth')
async function main() {
const conn = await mysql.createConnection({
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.database,
multipleStatements: true
})
const schema = fs.readFileSync(path.join(__dirname, '..', 'sql', 'schema.sql'), 'utf8')
await conn.query(schema)
await conn.query("ALTER TABLE devices MODIFY product_id VARCHAR(64) NOT NULL DEFAULT 'HOX_LIGHT_MASK'").catch(() => {})
const [rows] = await conn.execute('SELECT admin_id FROM admin_accounts WHERE username = ?', [config.admin.username])
if (rows.length === 0) {
const salt = randomHex(16)
const passwordHash = hashPassword(config.admin.password, salt)
await conn.execute(
'INSERT INTO admin_accounts (username, password_hash, password_salt, real_name, role, status) VALUES (?, ?, ?, ?, ?, 1)',
[config.admin.username, passwordHash, salt, '管理员', 'admin']
)
}
await conn.end()
console.log('Database initialized:', config.db.database)
}
main().catch(err => {
console.error(err)
process.exit(1)
})
+27
查看文件
@@ -0,0 +1,27 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') })
const http = require('http')
const config = require('../src/config')
const { handle } = require('../src/app')
const server = http.createServer(async (req, res) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', async () => {
const url = new URL(req.url, 'http://localhost')
const event = {
httpMethod: req.method,
path: url.pathname,
headers: req.headers,
queryStringParameters: Object.fromEntries(url.searchParams.entries()),
body: Buffer.concat(chunks).toString('utf8')
}
const result = await handle(event)
res.writeHead(result.statusCode, result.headers)
res.end(result.body || '')
})
})
server.listen(config.port, () => {
console.log('SCF local server listening on http://localhost:' + config.port)
})
+170
查看文件
@@ -0,0 +1,170 @@
CREATE TABLE IF NOT EXISTS users (
user_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
openid VARCHAR(64) NOT NULL,
nickname VARCHAR(100) NOT NULL DEFAULT '',
avatar VARCHAR(500) NOT NULL DEFAULT '',
phone VARCHAR(32) NOT NULL DEFAULT '',
gender TINYINT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
UNIQUE KEY uk_users_openid (openid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS devices (
device_id VARCHAR(32) NOT NULL,
product_id VARCHAR(64) NOT NULL DEFAULT 'HOX_LIGHT_MASK',
device_secret VARCHAR(128) NOT NULL DEFAULT '',
device_name VARCHAR(100) NOT NULL DEFAULT '',
firmware_version VARCHAR(32) NOT NULL DEFAULT '1.0.0',
hardware_version VARCHAR(32) NOT NULL DEFAULT '',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=inactive,2=online,3=offline,4=disabled',
battery TINYINT UNSIGNED NULL,
temperature TINYINT UNSIGNED NULL,
last_online_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS bindings (
binding_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
device_id VARCHAR(32) NOT NULL,
bind_token CHAR(16) NOT NULL DEFAULT '',
bind_expires DATETIME NULL,
bind_status TINYINT NOT NULL DEFAULT 1 COMMENT '1=active,2=inactive,3=pending',
bind_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
unbind_time DATETIME NULL,
PRIMARY KEY (binding_id),
KEY idx_bindings_user_status (user_id, bind_status),
KEY idx_bindings_device_status (device_id, bind_status),
CONSTRAINT fk_bindings_user FOREIGN KEY (user_id) REFERENCES users (user_id),
CONSTRAINT fk_bindings_device FOREIGN KEY (device_id) REFERENCES devices (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS subscriptions (
subscription_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
plan VARCHAR(32) NOT NULL,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=active,2=expired,3=cancelled',
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
order_id VARCHAR(64) NOT NULL DEFAULT '',
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expire_time DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (subscription_id),
KEY idx_subscriptions_user_status (user_id, status),
CONSTRAINT fk_subscriptions_user FOREIGN KEY (user_id) REFERENCES users (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS treatment_records (
record_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(64) NOT NULL,
device_id VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
start_time DATETIME NULL,
end_time DATETIME NULL,
regions VARCHAR(255) NOT NULL DEFAULT '',
total_duration_ms INT UNSIGNED NOT NULL DEFAULT 0,
mode TINYINT NOT NULL DEFAULT 0,
avg_pd DECIMAL(8,4) NOT NULL DEFAULT 0,
battery TINYINT UNSIGNED NULL,
temperature TINYINT UNSIGNED NULL,
wavelength TINYINT UNSIGNED NULL,
brightness TINYINT UNSIGNED NULL,
pd_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (record_id),
UNIQUE KEY uk_treatment_session (session_id),
KEY idx_treatment_user_created (user_id, created_at),
KEY idx_treatment_device_created (device_id, created_at),
CONSTRAINT fk_treatment_user FOREIGN KEY (user_id) REFERENCES users (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_events (
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NULL,
event_type VARCHAR(64) NOT NULL,
error_code INT NULL,
temperature TINYINT UNSIGNED NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (event_id),
KEY idx_device_events_device_created (device_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_commands (
command_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(32) NOT NULL,
admin_id BIGINT UNSIGNED NULL,
opcode TINYINT UNSIGNED NOT NULL,
payload_json JSON NULL,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=pending,2=pulled,3=done,4=failed,5=cancelled',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
pulled_at DATETIME NULL,
finished_at DATETIME NULL,
result_json JSON NULL,
PRIMARY KEY (command_id),
KEY idx_device_commands_device_status (device_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS operation_logs (
log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL,
admin_id BIGINT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
detail VARCHAR(1000) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (log_id),
KEY idx_operation_logs_created (created_at),
KEY idx_operation_logs_action (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_accounts (
admin_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password_hash CHAR(64) NOT NULL,
password_salt CHAR(32) NOT NULL,
real_name VARCHAR(100) NOT NULL DEFAULT '',
role VARCHAR(32) NOT NULL DEFAULT 'admin',
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (admin_id),
UNIQUE KEY uk_admin_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS system_settings (
setting_key VARCHAR(64) NOT NULL,
setting_value JSON NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS firmware_files (
firmware_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
version VARCHAR(32) NOT NULL,
device_type VARCHAR(32) NOT NULL DEFAULT '',
cos_key VARCHAR(500) NOT NULL,
size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
sha256 CHAR(64) NOT NULL DEFAULT '',
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (firmware_id),
KEY idx_firmware_version (version, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO system_settings (setting_key, setting_value) VALUES
('system_name', '"光子美容仪后台"'),
('monthly_price', '99'),
('yearly_price', '899'),
('trial_days', '7'),
('enable_register', 'true'),
('enable_binding', 'true'),
('enable_free_mode', 'true'),
('maintenance_mode', 'false');
+34
查看文件
@@ -0,0 +1,34 @@
const Router = require('./lib/router')
const { createContext } = require('./lib/request')
const { ok, fail, http } = require('./lib/response')
const router = new Router()
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)
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' }))
const match = router.match(ctx.method, ctx.path)
if (!match) return http(404, fail(404, 'not_found'))
ctx.params = match.params
try {
const body = await match.handler(ctx)
return http(200, body)
} catch (err) {
console.error(err)
return http(500, fail(3001, 'server_error'))
}
}
module.exports = { handle }
+35
查看文件
@@ -0,0 +1,35 @@
require('dotenv').config()
const config = {
nodeEnv: process.env.NODE_ENV || 'production',
port: parseInt(process.env.PORT, 10) || 3000,
region: process.env.TENCENT_REGION || 'ap-guangzhou',
db: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'jw_beauty'
},
cos: {
secretId: process.env.TENCENT_SECRET_ID,
secretKey: process.env.TENCENT_SECRET_KEY,
bucket: process.env.COS_BUCKET || 'jw-bucket-1426323813',
region: process.env.COS_REGION || process.env.TENCENT_REGION || 'ap-guangzhou'
},
wechat: {
appid: process.env.WECHAT_APPID,
secret: process.env.WECHAT_SECRET
},
jwt: {
secret: process.env.JWT_SECRET || 'dev-user-secret',
adminSecret: process.env.ADMIN_JWT_SECRET || 'dev-admin-secret',
expiresIn: '7d'
},
admin: {
username: process.env.ADMIN_USERNAME || 'admin',
password: process.env.ADMIN_PASSWORD || 'admin'
}
}
module.exports = config
+7
查看文件
@@ -0,0 +1,7 @@
const { handle } = require('./app')
exports.main_handler = async (event, context) => {
return handle(event, context)
}
exports.main = exports.main_handler
+52
查看文件
@@ -0,0 +1,52 @@
const crypto = require('crypto')
const jwt = require('jsonwebtoken')
const config = require('../config')
const { one } = require('./db')
function hashPassword(password, salt) {
return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex')
}
function randomHex(bytes) {
return crypto.randomBytes(bytes).toString('hex')
}
function signUser(user) {
return jwt.sign({ type: 'user', user_id: user.user_id, openid: user.openid }, config.jwt.secret, { expiresIn: config.jwt.expiresIn })
}
function signAdmin(admin) {
return jwt.sign({ type: 'admin', admin_id: admin.admin_id, username: admin.username, role: admin.role }, config.jwt.adminSecret, { expiresIn: config.jwt.expiresIn })
}
function readBearer(headers) {
const auth = headers.authorization || headers.Authorization || ''
const match = auth.match(/^Bearer\s+(.+)$/i)
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) || (ctx.body && ctx.body.token)
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, randomHex, signUser, signAdmin, requireUser, requireAdmin }
+38
查看文件
@@ -0,0 +1,38 @@
const COS = require('cos-nodejs-sdk-v5')
const config = require('../config')
let client
function getClient() {
if (!client) {
client = new COS({
SecretId: config.cos.secretId,
SecretKey: config.cos.secretKey
})
}
return client
}
function getObjectUrl(key, expiresSeconds) {
return getClient().getObjectUrl({
Bucket: config.cos.bucket,
Region: config.cos.region,
Key: key,
Sign: true,
Expires: expiresSeconds || 600
})
}
function getPutObjectUrl(key, contentType, expiresSeconds) {
return getClient().getObjectUrl({
Bucket: config.cos.bucket,
Region: config.cos.region,
Key: key,
Method: 'PUT',
Sign: true,
Expires: expiresSeconds || 600,
Headers: contentType ? { 'Content-Type': contentType } : undefined
})
}
module.exports = { getClient, getObjectUrl, getPutObjectUrl }
+48
查看文件
@@ -0,0 +1,48 @@
const mysql = require('mysql2/promise')
const config = require('../config')
let pool
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.database,
waitForConnections: true,
connectionLimit: 5,
namedPlaceholders: true,
timezone: '+08:00'
})
}
return pool
}
async function query(sql, params) {
const [rows] = await getPool().execute(sql, params || {})
return rows
}
async function one(sql, params) {
const rows = await query(sql, params)
return rows[0] || null
}
async function transaction(work) {
const conn = await getPool().getConnection()
try {
await conn.beginTransaction()
const result = await work(conn)
await conn.commit()
return result
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
}
module.exports = { getPool, query, one, transaction }
+16
查看文件
@@ -0,0 +1,16 @@
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 }
+45
查看文件
@@ -0,0 +1,45 @@
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 || ''
}
}
module.exports = { createContext }
+23
查看文件
@@ -0,0 +1,23 @@
function ok(data) {
return { code: 0, message: 'success', data: data || {} }
}
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 }
+33
查看文件
@@ -0,0 +1,33 @@
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
+76
查看文件
@@ -0,0 +1,76 @@
const https = require('https')
const config = require('../config')
function requestJson(url) {
return new Promise((resolve, reject) => {
https.get(url, res => {
let raw = ''
res.on('data', chunk => { raw += chunk })
res.on('end', () => {
try { resolve(JSON.parse(raw)) } catch (err) { reject(err) }
})
}).on('error', reject)
})
}
function postJson(url, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body || {})
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
let raw = ''
res.on('data', chunk => { raw += chunk })
res.on('end', () => {
try { resolve(JSON.parse(raw)) } catch (err) { reject(err) }
})
})
req.on('error', reject)
req.write(data)
req.end()
})
}
let accessTokenCache = null
async function getAccessToken() {
if (accessTokenCache && accessTokenCache.expiresAt > Date.now() + 60000) return accessTokenCache.token
if (!config.wechat.appid || !config.wechat.secret) throw new Error('WECHAT_APPID or WECHAT_SECRET is not configured')
const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=' + encodeURIComponent(config.wechat.appid) + '&secret=' + encodeURIComponent(config.wechat.secret)
const data = await requestJson(url)
if (!data.access_token) throw new Error(data.errmsg || 'wechat access_token failed')
accessTokenCache = {
token: data.access_token,
expiresAt: Date.now() + (Number(data.expires_in || 7200) * 1000)
}
return accessTokenCache.token
}
async function code2Session(code) {
if (config.nodeEnv === 'development' && (!code || code === 'local' || String(code).indexOf('dev_') === 0)) {
return { openid: 'dev_openid_' + String(code || 'local').slice(-8) }
}
if (!config.wechat.appid || !config.wechat.secret) {
if (config.nodeEnv === 'development') return { openid: 'dev_openid_' + String(code || 'local').slice(-8) }
throw new Error('WECHAT_APPID or WECHAT_SECRET is not configured')
}
const url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' + encodeURIComponent(config.wechat.appid) + '&secret=' + encodeURIComponent(config.wechat.secret) + '&js_code=' + encodeURIComponent(code) + '&grant_type=authorization_code'
const data = await requestJson(url)
if (!data.openid) throw new Error(data.errmsg || 'wechat login failed')
return data
}
async function getPhoneNumber(code) {
if (!code) throw new Error('phone code required')
const token = await getAccessToken()
const url = 'https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=' + encodeURIComponent(token)
const data = await postJson(url, { code })
if (data.errcode) throw new Error(data.errmsg || 'get phone number failed')
return data.phone_info || null
}
module.exports = { code2Session, getPhoneNumber }
+193
查看文件
@@ -0,0 +1,193 @@
const { one, query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { hashPassword, signAdmin, requireAdmin } = require('../lib/auth')
const { writeLog } = require('../lib/log')
function pageParams(ctx) {
const page = Math.max(1, parseInt(ctx.query.page || ctx.body.page, 10) || 1)
const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size || ctx.body.page_size, 10) || 20), 100)
return { page, pageSize, offset: (page - 1) * pageSize }
}
function limitClause(p) {
return ' LIMIT ' + Number(p.pageSize) + ' OFFSET ' + Number(p.offset)
}
async function adminOnly(ctx) {
const admin = await requireAdmin(ctx)
return admin
}
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 || hashPassword(password, admin.password_salt) !== admin.password_hash) 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 })
})
router.get('/api/v1/admin/dashboard', async ctx => {
const admin = await adminOnly(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()', {})
])
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 })
})
router.get('/api/v1/admin/devices', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const total = await query('SELECT COUNT(*) AS total FROM devices', {})
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 ORDER BY d.created_at DESC' + limitClause(p), {})
return ok({ records, total: total[0].total })
})
router.post('/api/v1/admin/devices', async ctx => {
const admin = await adminOnly(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('/api/v1/admin/devices/:device_id', async ctx => {
const admin = await adminOnly(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')
return ok(device)
})
router.post('/api/v1/admin/devices/:device_id/unbind', async ctx => {
const admin = await adminOnly(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('/api/v1/admin/devices/:device_id/command', async ctx => {
const admin = await adminOnly(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.get('/api/v1/admin/devices/:device_id/commands', async ctx => {
const admin = await adminOnly(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),
{ device_id: ctx.params.device_id }
)
return ok({ records, total: total[0].total })
})
router.get('/api/v1/admin/users', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const total = await query('SELECT COUNT(*) AS total FROM users', {})
const records = await query('SELECT * FROM users ORDER BY created_at DESC' + limitClause(p), {})
return ok({ records, total: total[0].total })
})
router.get('/api/v1/admin/users/:user_id', async ctx => {
const admin = await adminOnly(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 })
return ok(Object.assign({}, user, { devices, recent_treatments: treatments }))
})
router.get('/api/v1/admin/subscriptions', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const total = await query('SELECT COUNT(*) AS total FROM subscriptions', {})
const records = await query('SELECT * FROM subscriptions ORDER BY created_at DESC' + limitClause(p), {})
return ok({ records, total: total[0].total })
})
router.post('/api/v1/admin/subscriptions', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
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('/api/v1/admin/records', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const total = await query('SELECT COUNT(*) AS total FROM treatment_records', {})
const records = await query('SELECT * FROM treatment_records ORDER BY created_at DESC' + limitClause(p), {})
return ok({ records, total: total[0].total })
})
router.get('/api/v1/admin/logs', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const total = await query('SELECT COUNT(*) AS total FROM operation_logs', {})
const records = await query('SELECT * FROM operation_logs ORDER BY created_at DESC' + limitClause(p), {})
return ok({ records, total: total[0].total })
})
router.get('/api/v1/admin/settings', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const rows = await query('SELECT setting_key, setting_value FROM system_settings', {})
const settings = {}
rows.forEach(row => {
settings[row.setting_key] = typeof row.setting_value === 'string' ? JSON.parse(row.setting_value) : row.setting_value
})
return ok(settings)
})
router.post('/api/v1/admin/settings', async ctx => {
const admin = await adminOnly(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
for (const key of Object.keys(ctx.body || {})) {
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
+40
查看文件
@@ -0,0 +1,40 @@
const { one, query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { signUser } = require('../lib/auth')
const { code2Session } = require('../lib/wechat')
const { writeLog } = require('../lib/log')
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,
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
})
})
router.post('/api/v1/auth/refresh', async ctx => {
return fail(2001, 'refresh_token 暂未启用,请重新登录')
})
}
module.exports = register
+150
查看文件
@@ -0,0 +1,150 @@
const { one, query, transaction } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { requireUser, randomHex } = require('../lib/auth')
const { writeLog } = require('../lib/log')
function formatDate(date) {
return date.toISOString().slice(0, 19).replace('T', ' ')
}
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
const expire = formatDate(new Date(Date.now() + 7 * 24 * 3600 * 1000))
await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, ?, 1, 0, NOW(), ?)', [userId, 'trial', expire])
}
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 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 }
const bindToken = randomHex(8)
const bindExpires = formatDate(new Date(Date.now() + 10 * 60 * 1000))
await conn.execute(
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, ?, 3, NOW())',
[user.user_id, deviceId, bindToken, bindExpires]
)
return { device_id: deviceId, bind_token: bindToken, bind_expires: bindExpires }
})
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 })
return ok(Object.assign(result, { subscription: { plan: 'trial', remaining_days: 7 } }))
})
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')
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 })
return ok({ message: 'success', subscription: { plan: 'trial', remaining_days: 7 } })
})
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')
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')
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
+66
查看文件
@@ -0,0 +1,66 @@
const { one, query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { requireUser, requireAdmin } = require('../lib/auth')
const { getObjectUrl } = require('../lib/cos')
const { writeLog } = require('../lib/log')
function adminOnly(ctx) {
return requireAdmin(ctx)
}
function register(router) {
router.get('/api/v1/admin/firmware', async ctx => {
const admin = await adminOnly(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 })
})
router.post('/api/v1/admin/firmware', async ctx => {
const admin = await adminOnly(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('/api/v1/admin/firmware/:firmware_id/status', async ctx => {
const admin = await adminOnly(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('/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 })
return ok({
has_update: true,
version: firmware.version,
size_bytes: firmware.size_bytes,
sha256: firmware.sha256,
download_url: getObjectUrl(firmware.cos_key, 600)
})
})
}
module.exports = register
+48
查看文件
@@ -0,0 +1,48 @@
const { one, query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { requireUser } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const PLANS = {
monthly: { amount: 99, days: 30 },
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.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('/api/v1/subscription/verify', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const plan = ctx.body.plan || ctx.body.plan_type || 'monthly'
if (!PLANS[plan]) return fail(2001, 'invalid plan')
const p = PLANS[plan]
await query('UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', { user_id: user.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: user.user_id,
plan,
amount: p.amount,
order_id: ctx.body.order_id || 'ORD' + Date.now(),
days: p.days
})
await writeLog({ user_id: user.user_id, action: 'subscription_verify', detail: '订阅生效: ' + plan, ip: ctx.ip })
return ok({ status: 'active', plan, remaining_days: p.days })
})
}
module.exports = register
+67
查看文件
@@ -0,0 +1,67 @@
const { query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { requireUser } = require('../lib/auth')
const { writeLog } = require('../lib/log')
function toMysqlDate(value) {
if (!value) return null
const d = new Date(value)
if (Number.isNaN(d.getTime())) return null
return d.toISOString().slice(0, 19).replace('T', ' ')
}
function register(router) {
function limitClause(pageSize, offset) {
return ' LIMIT ' + Number(pageSize) + ' OFFSET ' + Number(offset)
}
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.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 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 })
})
}
module.exports = register
+67
查看文件
@@ -0,0 +1,67 @@
const { query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { requireUser } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const { getPhoneNumber } = require('../lib/wechat')
const { getPutObjectUrl, getObjectUrl } = require('../lib/cos')
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
})
})
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('/api/v1/user/avatar/upload-url', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const ext = String(ctx.body.ext || 'jpg').replace(/[^a-zA-Z0-9]/g, '').toLowerCase() || 'jpg'
const contentType = ctx.body.content_type || (ext === 'png' ? 'image/png' : 'image/jpeg')
const key = 'avatars/' + user.user_id + '/' + Date.now() + '.' + ext
return ok({
key,
upload_url: getPutObjectUrl(key, contentType, 600),
public_url: getObjectUrl(key, 7 * 24 * 3600),
content_type: contentType
})
})
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