refactor: migrate to Tencent Cloud backend
这个提交包含在:
@@ -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
|
||||
@@ -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。
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./src/index')
|
||||
+1089
文件差异内容过多而无法显示
加载差异
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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');
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
const { handle } = require('./app')
|
||||
|
||||
exports.main_handler = async (event, context) => {
|
||||
return handle(event, context)
|
||||
}
|
||||
|
||||
exports.main = exports.main_handler
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
在新工单中引用
屏蔽一个用户