refactor: restructure entire project for human maintainability

Server:
- Add Express framework, replace custom router/request parser
- Create DAO layer (12 files) centralizing all 73 SQL queries
- Rewrite 7 route files as thin Express controllers calling DAOs
- Add SCF-to-Express adapter (lib/serverless.js)
- Add auth middleware (middleware/auth.js)
- Remove dead code from lib/auth.js

Admin console:
- Extract DataTable component (table + pagination)
- Extract ConfirmModal component (modal + form styles)
- Create listMixin for paginated list pages
- Move form styles to common.css for slot compatibility
- Refactor device + subscription pages as examples

Miniprogram:
- Split 734-line BLE monolith into 4 focused modules
  (protocol, connection, commands, barrel index)
- Create API module (utils/api.js) with named functions
- Create page utilities (utils/page.js)
- Refactor index + profile pages to use API module
这个提交包含在:
Guoguo
2026-04-29 05:58:20 -07:00
父节点 9f0e629c82
当前提交 bb4b80f867
修改 46 个文件,包含 4306 行新增2020 行删除
@@ -0,0 +1,58 @@
<template>
<view class="modal-mask" v-if="visible" @click="$emit('close')">
<view class="modal-content" @click.stop>
<view class="modal-title">{{ title }}</view>
<slot></slot>
<view class="modal-actions">
<button class="btn-default" @click="$emit('close')">取消</button>
<button class="btn-primary" @click="$emit('confirm')" :loading="loading">{{ confirmText || '确定' }}</button>
</view>
</view>
</view>
</template>
<script>
export default {
props: {
visible: Boolean,
title: String,
loading: Boolean,
confirmText: String
}
}
</script>
<style scoped>
@import '../styles/common.css';
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 480px;
background: #fff;
border-radius: 8px;
padding: 24px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>
@@ -0,0 +1,57 @@
<template>
<view class="page-card">
<slot name="toolbar"></slot>
<view class="data-table">
<view class="t-header">
<view class="t-row">
<text
v-for="col in columns"
:key="col.key"
:class="'t-th flex' + (col.flex || 1)"
>{{ col.label }}</text>
</view>
</view>
<view class="t-body">
<slot name="rows"></slot>
<view v-if="records.length === 0" class="empty-row">
<text class="empty-text">暂无数据</text>
</view>
</view>
</view>
<view class="pagination" v-if="totalPages > 1">
<button class="btn-page" :disabled="page <= 1" @click="$emit('page', page - 1)"></button>
<button class="btn-page active">{{ page }}</button>
<button class="btn-page" :disabled="page >= totalPages" @click="$emit('page', page + 1)"></button>
<text class="page-info"> {{ totalPages }} </text>
</view>
</view>
</template>
<script>
export default {
props: {
columns: { type: Array, required: true },
records: { type: Array, default: function () { return [] } },
page: { type: Number, default: 1 },
totalPages: { type: Number, default: 1 }
}
}
</script>
<style scoped>
@import '../styles/common.css';
.empty-row {
padding: 40px 0;
text-align: center;
}
.empty-text {
color: #999;
font-size: 14px;
}
.pagination {
justify-content: flex-end;
}
</style>
+69 -146
查看文件
@@ -16,61 +16,46 @@
</view>
</view>
<view class="page-card">
<view class="data-table">
<view class="t-header">
<view class="t-row">
<text class="t-th flex2">设备编号</text>
<text class="t-th flex2">产品编号</text>
<text class="t-th flex2">绑定用户</text>
<text class="t-th flex1">电量</text>
<text class="t-th flex1">固件版本</text>
<text class="t-th flex2">绑定时间</text>
<text class="t-th flex1">状态</text>
<text class="t-th flex1">操作</text>
</view>
<DataTable
:columns="columns"
:records="records"
:page="page"
:totalPages="totalPages"
@page="onPage"
>
<template #rows>
<view class="t-row" v-for="item in records" :key="item.device_id">
<text class="t-td flex2">{{ item.device_id }}</text>
<text class="t-td flex2">{{ item.product_id || 'HOX_LIGHT_MASK' }}</text>
<text class="t-td flex2">{{ item.bound_user || '-' }}</text>
<text class="t-td flex1">{{ item.battery != null ? item.battery + '%' : '-' }}</text>
<text class="t-td flex1">{{ item.firmware_version || item.fw_version || '-' }}</text>
<text class="t-td flex2">{{ formatDate(item.activated_at) }}</text>
<text class="t-td flex1">
<text :class="statusBadge(item.status)">{{ statusMap[item.status] || '未知' }}</text>
</text>
<text class="t-td flex1">
<text class="action-link" @click="onDetail(item.device_id)">详情</text>
<text class="action-link" v-if="item.bound_user" @click="onUnbind(item)">解绑</text>
</text>
</view>
<view class="t-body">
<view class="t-row" v-for="item in devices" :key="item.device_id">
<text class="t-td flex2">{{ item.device_id }}</text>
<text class="t-td flex2">{{ item.product_id || 'HOX_LIGHT_MASK' }}</text>
<text class="t-td flex2">{{ item.bound_user || '-' }}</text>
<text class="t-td flex1">{{ item.battery != null ? item.battery + '%' : '-' }}</text>
<text class="t-td flex1">{{ item.firmware_version || item.fw_version || '-' }}</text>
<text class="t-td flex2">{{ formatDate(item.activated_at) }}</text>
<text class="t-td flex1">
<text :class="statusBadge(item.status)">{{ statusMap[item.status] || '未知' }}</text>
</text>
<text class="t-td flex1">
<text class="action-link" @click="onDetail(item.device_id)">详情</text>
<text class="action-link" v-if="item.bound_user" @click="onUnbind(item)">解绑</text>
</text>
</view>
</view>
</view>
</template>
</DataTable>
<view class="pagination">
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)"></button>
<button class="btn-page active">{{ page }}</button>
<button class="btn-page" :disabled="page >= totalPages" @click="onPage(page + 1)"></button>
<text class="page-info"> {{ totalPages }} </text>
<ConfirmModal
:visible="showBatchImport"
title="批量导入设备"
confirmText="导入"
:loading="batchImporting"
@close="showBatchImport = false"
@confirm="onBatchImport"
>
<view class="form-group">
<text class="form-label">设备编号每行一个</text>
<textarea class="form-textarea" v-model="batchDeviceIds" placeholder="输入设备编号,每行一个&#10;例如:&#10;HOX001&#10;HOX002&#10;HOX003" :maxlength="-1"></textarea>
</view>
</view>
<view class="modal-mask" v-if="showBatchImport" @click="showBatchImport = false">
<view class="modal-content" @click.stop>
<view class="modal-title">批量导入设备</view>
<view class="form-group">
<text class="form-label">设备编号每行一个</text>
<textarea class="form-textarea" v-model="batchDeviceIds" placeholder="输入设备编号,每行一个&#10;例如:&#10;HOX001&#10;HOX002&#10;HOX003" :maxlength="-1"></textarea>
</view>
<view class="batch-hint">最多 500 个设备</view>
<view class="modal-actions">
<button class="btn-default" @click="showBatchImport = false">取消</button>
<button class="btn-primary" @click="onBatchImport" :loading="batchImporting">导入</button>
</view>
</view>
</view>
<view class="batch-hint">最多 500 个设备</view>
</ConfirmModal>
</AdminLayout>
</template>
@@ -78,54 +63,48 @@
import { get, post } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDateShort } from '../../utils/format'
import { listMixin } from '../../utils/useList'
import AdminLayout from '../../components/AdminLayout.vue'
import DataTable from '../../components/DataTable.vue'
import ConfirmModal from '../../components/ConfirmModal.vue'
var STATUS_MAP = { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
var STATUS_BADGE = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' }
export default {
components: { AdminLayout },
components: { AdminLayout, DataTable, ConfirmModal },
mixins: [listMixin('/api/v1/admin/devices')],
data() {
return {
devices: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' },
statusMap: STATUS_MAP,
columns: [
{ key: 'device_id', label: '设备编号', flex: 2 },
{ key: 'product_id', label: '产品编号', flex: 2 },
{ key: 'bound_user', label: '绑定用户', flex: 2 },
{ key: 'battery', label: '电量', flex: 1 },
{ key: 'fw', label: '固件版本', flex: 1 },
{ key: 'activated_at', label: '绑定时间', flex: 2 },
{ key: 'status', label: '状态', flex: 1 },
{ key: 'actions', label: '操作', flex: 1 }
],
showBatchImport: false,
batchDeviceIds: '',
batchImporting: false
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadDevices()
this.reload()
},
methods: {
async loadDevices() {
try {
const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize, keyword: this.keyword })
this.devices = data.records || []
this.total = data.total || 0
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
onSearch() {
this.page = 1
this.loadDevices()
},
onPage(p) {
this.page = p
this.loadDevices()
reload() {
this.loadList({ keyword: this.keyword })
},
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
},
onCreateDevice() {
const self = this
var self = this
uni.showModal({
title: '预生成产品码',
editable: true,
@@ -135,7 +114,7 @@ export default {
try {
await post('/api/v1/admin/devices', { device_id: res.content.trim(), product_id: 'HOX_LIGHT_MASK' })
uni.showToast({ title: '创建成功', icon: 'success' })
self.loadDevices()
self.reload()
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
}
@@ -143,7 +122,7 @@ export default {
})
},
onUnbind(item) {
const self = this
var self = this
uni.showModal({
title: '确认解绑',
content: '确定要解绑设备 ' + item.device_id + ' 吗?',
@@ -152,7 +131,7 @@ export default {
try {
await post('/api/v1/admin/devices/' + item.device_id + '/unbind', {})
uni.showToast({ title: '解绑成功', icon: 'success' })
self.loadDevices()
self.reload()
} catch (e) {
uni.showToast({ title: '解绑失败', icon: 'none' })
}
@@ -161,12 +140,11 @@ export default {
})
},
statusBadge(status) {
const map = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' }
return map[status] || 'badge badge-default'
return STATUS_BADGE[status] || 'badge badge-default'
},
formatDate: formatDateShort,
async onBatchImport() {
const ids = this.batchDeviceIds.split('\n').map(s => s.trim()).filter(Boolean)
var ids = this.batchDeviceIds.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
if (ids.length === 0) {
uni.showToast({ title: '请输入设备编号', icon: 'none' })
return
@@ -177,11 +155,11 @@ export default {
}
this.batchImporting = true
try {
const result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
var result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
uni.showToast({ title: '导入 ' + result.created + ' 个设备', icon: 'success' })
this.showBatchImport = false
this.batchDeviceIds = ''
this.loadDevices()
this.reload()
} catch (e) {
uni.showToast({ title: '导入失败', icon: 'none' })
} finally {
@@ -190,8 +168,8 @@ export default {
},
async onExport() {
try {
const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
const records = data.records || []
var data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
var records = data.records || []
exportCSV('devices_' + new Date().toISOString().slice(0, 10) + '.csv',
['设备编号', '产品编号', '绑定用户', '电量', '固件版本', '绑定时间', '状态'],
records.map(function (r) {
@@ -210,64 +188,9 @@ export default {
<style scoped>
@import '../../styles/common.css';
.pagination {
justify-content: flex-end;
}
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 480px;
background: #fff;
border-radius: 8px;
padding: 24px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 6px;
}
.form-textarea {
width: 100%;
height: 160px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 12px;
font-size: 14px;
box-sizing: border-box;
resize: vertical;
}
.batch-hint {
font-size: 12px;
color: #999;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
+89 -166
查看文件
@@ -28,76 +28,64 @@
</view>
</view>
<view class="page-card">
<view class="tabs">
<view class="tab" :class="{ active: activeTab === 'all' }" @click="onTabFilter('all')">全部订阅</view>
<view class="tab" :class="{ active: activeTab === 'monthly' }" @click="onTabFilter('monthly')">月卡</view>
<view class="tab" :class="{ active: activeTab === 'yearly' }" @click="onTabFilter('yearly')">年卡</view>
<view class="tab" :class="{ active: activeTab === 'trial' }" @click="onTabFilter('trial')">试用中</view>
<view class="tab" :class="{ active: activeTab === 'expired' }" @click="onTabFilter('expired')">已过期</view>
</view>
<DataTable
:columns="columns"
:records="records"
:page="page"
:totalPages="totalPages"
@page="onPage"
>
<template #toolbar>
<view class="tabs">
<view class="tab" :class="{ active: activeTab === 'all' }" @click="onTabFilter('all')">全部订阅</view>
<view class="tab" :class="{ active: activeTab === 'monthly' }" @click="onTabFilter('monthly')">月卡</view>
<view class="tab" :class="{ active: activeTab === 'yearly' }" @click="onTabFilter('yearly')">年卡</view>
<view class="tab" :class="{ active: activeTab === 'trial' }" @click="onTabFilter('trial')">试用中</view>
<view class="tab" :class="{ active: activeTab === 'expired' }" @click="onTabFilter('expired')">已过期</view>
</view>
</template>
<view class="data-table">
<view class="t-header">
<view class="t-row">
<text class="t-th flex2">用户</text>
<text class="t-th flex1">订阅类型</text>
<text class="t-th flex1">订单金额</text>
<text class="t-th flex2">开始日期</text>
<text class="t-th flex2">到期日期</text>
<text class="t-th flex1">状态</text>
<text class="t-th flex1">操作</text>
</view>
<template #rows>
<view class="t-row" v-for="item in records" :key="item.id">
<text class="t-td flex2">{{ item.nickname || item.user_id }}</text>
<text class="t-td flex1">{{ planText(item.plan) }}</text>
<text class="t-td flex1">¥{{ item.amount || '-' }}</text>
<text class="t-td flex2">{{ formatDate(item.start_time) }}</text>
<text class="t-td flex2">{{ formatDate(item.expire_time) }}</text>
<text class="t-td flex1">
<text :class="statusBadge(item.status)">{{ statusText(item.status) }}</text>
</text>
<text class="t-td flex1">
<text class="action-link" @click="onDetail(item)">详情</text>
<text class="action-link" v-if="item.status === 1" @click="onExtend(item)">延期</text>
<text class="action-link" v-if="item.status === 1" @click="onCancel(item)">取消</text>
<text class="action-link" v-if="item.status === 2 || item.status === 3" @click="onRenew(item)">续费</text>
</text>
</view>
<view class="t-body">
<view class="t-row" v-for="item in subscriptions" :key="item.id">
<text class="t-td flex2">{{ item.nickname || item.user_id }}</text>
<text class="t-td flex1">{{ planText(item.plan) }}</text>
<text class="t-td flex1">¥{{ item.amount || '-' }}</text>
<text class="t-td flex2">{{ formatDate(item.start_time) }}</text>
<text class="t-td flex2">{{ formatDate(item.expire_time) }}</text>
<text class="t-td flex1">
<text :class="statusBadge(item.status)">{{ statusText(item.status) }}</text>
</text>
<text class="t-td flex1">
<text class="action-link" @click="onDetail(item)">详情</text>
<text class="action-link" v-if="item.status === 1" @click="onExtend(item)">延期</text>
<text class="action-link" v-if="item.status === 1" @click="onCancel(item)">取消</text>
<text class="action-link" v-if="item.status === 2 || item.status === 3" @click="onRenew(item)">续费</text>
</text>
</view>
</view>
</view>
</template>
</DataTable>
<view class="pagination">
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)"></button>
<button class="btn-page active">{{ page }}</button>
<button class="btn-page" :disabled="page >= totalPages" @click="onPage(page + 1)"></button>
<text class="page-info"> {{ totalPages }} </text>
<ConfirmModal
:visible="showCreate"
title="创建订阅"
confirmText="创建"
:loading="creating"
@close="showCreate = false"
@confirm="onCreate"
>
<view class="form-group">
<text class="form-label">用户ID</text>
<input class="form-input" v-model="createForm.user_id" placeholder="输入用户ID" />
</view>
</view>
<view class="modal-mask" v-if="showCreate" @click="showCreate = false">
<view class="modal-content" @click.stop>
<view class="modal-title">创建订阅</view>
<view class="form-group">
<text class="form-label">用户ID</text>
<input class="form-input" v-model="createForm.user_id" placeholder="输入用户ID" />
</view>
<view class="form-group">
<text class="form-label">方案</text>
<input class="form-input" v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
</view>
<view class="form-group">
<text class="form-label">天数</text>
<input class="form-input" v-model="createForm.days" placeholder="订阅天数" type="number" />
</view>
<view class="modal-actions">
<button class="btn-default" @click="showCreate = false">取消</button>
<button class="btn-primary" @click="onCreate" :loading="creating">创建</button>
</view>
<view class="form-group">
<text class="form-label">方案</text>
<input class="form-input" v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
</view>
</view>
<view class="form-group">
<text class="form-label">天数</text>
<input class="form-input" v-model="createForm.days" placeholder="订阅天数" type="number" />
</view>
</ConfirmModal>
</AdminLayout>
</template>
@@ -105,50 +93,50 @@
import { get, post } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDateShort } from '../../utils/format'
import { listMixin } from '../../utils/useList'
import AdminLayout from '../../components/AdminLayout.vue'
import DataTable from '../../components/DataTable.vue'
import ConfirmModal from '../../components/ConfirmModal.vue'
var PLAN_MAP = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
var STATUS_TEXT = { 1: '生效中', 2: '已过期', 3: '已取消' }
var STATUS_BADGE = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
export default {
components: { AdminLayout },
components: { AdminLayout, DataTable, ConfirmModal },
mixins: [listMixin('/api/v1/admin/subscriptions')],
data() {
return {
subscriptions: [],
total: 0,
page: 1,
pageSize: 20,
stats: {},
activeTab: 'all',
columns: [
{ key: 'user', label: '用户', flex: 2 },
{ key: 'plan', label: '订阅类型', flex: 1 },
{ key: 'amount', label: '订单金额', flex: 1 },
{ key: 'start', label: '开始日期', flex: 2 },
{ key: 'expire', label: '到期日期', flex: 2 },
{ key: 'status', label: '状态', flex: 1 },
{ key: 'actions', label: '操作', flex: 1 }
],
showCreate: false,
creating: false,
createForm: { user_id: '', plan: 'monthly', days: 30 },
activeTab: 'all'
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
createForm: { user_id: '', plan: 'monthly', days: 30 }
}
},
onShow() {
this.loadSubscriptions()
this.reload()
},
methods: {
async loadSubscriptions() {
try {
const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize, tab: this.activeTab })
this.subscriptions = data.records || []
this.total = data.total || 0
if (data.stats) this.stats = data.stats
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
reload() {
this.loadList({ tab: this.activeTab })
},
onListLoaded(data) {
if (data.stats) this.stats = data.stats
},
onTabFilter(tab) {
this.activeTab = tab
this.page = 1
this.loadSubscriptions()
},
onPage(p) {
this.page = p
this.loadSubscriptions()
this.reload()
},
async onCreate() {
this.creating = true
@@ -159,7 +147,7 @@ export default {
days: parseInt(this.createForm.days)
})
this.showCreate = false
this.loadSubscriptions()
this.reload()
uni.showToast({ title: '创建成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
@@ -186,7 +174,7 @@ export default {
days: 30
})
uni.showToast({ title: '延期成功', icon: 'success' })
self.loadSubscriptions()
self.reload()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
@@ -206,7 +194,7 @@ export default {
subscription_id: item.subscription_id
})
uni.showToast({ title: '已取消', icon: 'success' })
self.loadSubscriptions()
self.reload()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
@@ -220,29 +208,18 @@ export default {
this.createForm.days = 30
this.showCreate = true
},
planText(plan) {
const map = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
return map[plan] || plan || '-'
},
statusText(status) {
const map = { 1: '生效中', 2: '已过期', 3: '已取消' }
return map[status] || '-'
},
statusBadge(status) {
const map = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
return map[status] || 'badge badge-default'
},
planText(plan) { return PLAN_MAP[plan] || plan || '-' },
statusText(status) { return STATUS_TEXT[status] || '-' },
statusBadge(status) { return STATUS_BADGE[status] || 'badge badge-default' },
formatDate: formatDateShort,
async onExport() {
try {
const data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
const records = data.records || []
var planMap = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
var statusMap = { 1: '生效中', 2: '已过期', 3: '已取消' }
var data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
var records = data.records || []
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
records.map(function (r) {
return [r.nickname || r.user_id || '', planMap[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', statusMap[r.status] || '']
return [r.nickname || r.user_id || '', PLAN_MAP[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', STATUS_TEXT[r.status] || '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
@@ -308,58 +285,4 @@ export default {
.action-link {
margin-right: 8px;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 440px;
background: #fff;
border-radius: 8px;
padding: 24px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 6px;
}
.form-input {
width: 100%;
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>
+32
查看文件
@@ -171,3 +171,35 @@
.input-placeholder {
color: #bfbfbf;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 6px;
}
.form-input {
width: 100%;
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
height: 160px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 12px;
font-size: 14px;
box-sizing: border-box;
resize: vertical;
}
+81
查看文件
@@ -0,0 +1,81 @@
import { get } from './request'
/**
* Creates a mixin for paginated list pages.
*
* Eliminates the repeated data/computed/methods boilerplate for:
* - records[], total, page, pageSize, totalPages
* - loadList(), onPage(), onSearch()
*
* Usage:
* import { listMixin } from '../../utils/useList'
*
* export default {
* mixins: [listMixin('/api/v1/admin/devices')],
* methods: {
* reload() {
* this.loadList({ keyword: this.keyword })
* }
* }
* }
*
* @param {string} apiPath - API endpoint path (passed to get())
* @param {object} opts
* @param {number} [opts.pageSize=20] - Items per page
*/
export function listMixin(apiPath, opts = {}) {
return {
data() {
return {
records: [],
total: 0,
page: 1,
pageSize: opts.pageSize || 20
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
methods: {
async loadList(extraParams = {}) {
try {
var params = { page: this.page, page_size: this.pageSize }
// Merge extra params, dropping falsy values to keep request clean
var keys = Object.keys(extraParams)
for (var i = 0; i < keys.length; i++) {
var k = keys[i]
if (extraParams[k] !== '' && extraParams[k] != null) {
params[k] = extraParams[k]
}
}
var data = await get(apiPath, params)
this.records = data.records || []
this.total = data.total || 0
this.onListLoaded(data)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
/**
* Override in page to handle extra response data (e.g. stats).
*/
onListLoaded(_data) {},
onPage(p) {
this.page = p
this.reload()
},
onSearch() {
this.page = 1
this.reload()
},
/**
* Override in page to call loadList() with current filters.
*/
reload() {
this.loadList()
}
}
}
}
+5 -5
查看文件
@@ -1,5 +1,5 @@
var ble = require('../../services/ble')
var http = require('../../utils/request')
var api = require('../../utils/api')
var config = require('../../config/env')
var app = getApp()
@@ -49,7 +49,7 @@ Page({
self.setData({ connected: ble.isConnected() })
http.get('/api/v1/device/list').then(function (data) {
api.getDevices().then(function (data) {
var devices = data.devices || []
self.setData({ hasDevice: devices.length > 0 })
if (devices.length > 0) {
@@ -61,7 +61,7 @@ Page({
}
}).catch(function () {})
http.get('/api/v1/subscription').then(function (sub) {
api.getSubscription().then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
@@ -125,7 +125,7 @@ Page({
var self = this
wx.showLoading({ title: '解绑中...' })
ble.disconnect()
http.post('/api/v1/device/unbind', {}).then(function () {
api.unbindDevice().then(function () {
wx.hideLoading()
self.setData({
hasDevice: false,
@@ -157,7 +157,7 @@ Page({
success: function (res) {
if (res.confirm && res.content) {
var deviceId = res.content.trim()
http.post('/api/v1/device/mock-bind', { device_id: deviceId }).then(function () {
api.mockBind(deviceId).then(function () {
wx.showToast({ title: '模拟绑定成功', icon: 'success' })
self.checkState()
}).catch(function (err) {
+4 -4
查看文件
@@ -1,4 +1,4 @@
var http = require('../../utils/request')
var api = require('../../utils/api')
var app = getApp()
Page({
@@ -15,14 +15,14 @@ Page({
loadProfile: function () {
var self = this
http.get('/api/v1/user/profile').then(function (profile) {
api.getProfile().then(function (profile) {
self.setData({
userInfo: profile,
deviceCount: profile.device_count || 0
})
}).catch(function () {})
http.get('/api/v1/subscription').then(function (sub) {
api.getSubscription().then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
@@ -42,7 +42,7 @@ Page({
content: '解绑后将无法使用该设备,确定要解绑吗?',
success: function (modalRes) {
if (modalRes.confirm) {
http.post('/api/v1/device/unbind', {}).then(function () {
api.unbindDevice().then(function () {
wx.showToast({ title: '已解绑', icon: 'success' })
self.loadProfile()
}).catch(function (err) {
-734
查看文件
@@ -1,734 +0,0 @@
var SERVICE = {
DEVICE_INFO: 'FFE0',
DATA_COMM: 'FFE1',
OTA: 'FFE2'
}
var CHAR = {
DEVICE_INFO: 'FFE3',
COMMAND: 'FFE4',
STATUS: 'FFE5',
BOND_INFO: 'FFE6',
OTA_CONTROL: 'FFE7',
OTA_DATA: 'FFE8',
OTA_STATUS: 'FFE9'
}
var CMD = {
SET_PARAMS: 0x01,
START: 0x02,
STOP: 0x03,
QUERY_STATUS: 0x04,
BIND: 0x05,
UNBIND: 0x06
}
var NOTIFY = {
STATUS_REPORT: 0x21,
ACK: 0x22,
TREATMENT_COMPLETE: 0x31,
EXCEPTION: 0x32,
BIND_SUCCESS: 0x33
}
var MODE_STATE = {
IDLE: 0x00,
SCANNING: 0x01,
ACTIVE: 0x02,
PAUSED: 0x03,
COMPLETED: 0x04,
ERROR: 0x05,
OTA: 0x06
}
var WAVELENGTH = {
IR: 1,
R: 2,
UV: 3,
Y: 4
}
var TREAT_MODE = {
NORMAL: 0,
SMART: 1
}
var REGION = {
LEFT_CHEEK: 0x01,
RIGHT_CHEEK: 0x02,
FOREHEAD: 0x04,
CHIN: 0x08,
NOSE: 0x10,
LEFT_EYE: 0x20,
RIGHT_EYE: 0x40,
FULL_FACE: 0x7F
}
var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
var DEVICE_ERR = {
0x00: 'SUCCESS',
0x01: 'ERR_REGION_INVALID',
0x02: 'ERR_REGION_EMPTY',
0x03: 'ERR_BRIGHTNESS_INVALID',
0x04: 'ERR_DURATION_INVALID',
0x05: 'ERR_NOT_BOUND',
0x06: 'ERR_NO_SUBSCRIPTION',
0x07: 'ERR_TEMP_HIGH',
0x08: 'ERR_BATTERY_LOW',
0x09: 'ERR_ALREADY_RUNNING',
0x0A: 'ERR_NOT_RUNNING',
0x0B: 'ERR_OTA_FAILED',
0x0C: 'ERR_BLE_DISCONNECTED'
}
var _deviceId = null
var _connected = false
var _chars = {}
var _cmdSeq = 0
var _pendingAcks = {}
var _listeners = {}
var _autoReconnect = true
var _reconnecting = false
function nextSeq() {
_cmdSeq = (_cmdSeq + 1) % 256
return _cmdSeq
}
function bufferToBytes(buffer) {
var arr = new Uint8Array(buffer)
var bytes = []
for (var i = 0; i < arr.length; i++) {
bytes.push(arr[i])
}
return bytes
}
function bytesToBuffer(bytes) {
var buffer = new ArrayBuffer(bytes.length)
var view = new Uint8Array(buffer)
for (var i = 0; i < bytes.length; i++) {
view[i] = bytes[i]
}
return buffer
}
function xorChecksum(bytes) {
var result = 0
for (var i = 0; i < bytes.length; i++) {
result ^= bytes[i]
}
return result
}
function buildFrame(type, payload) {
var len = payload ? payload.length : 0
var frame = [0xAA, 0x55, len, type]
if (payload && payload.length > 0) {
frame = frame.concat(payload)
}
var checkBytes = frame.slice(0)
frame.push(xorChecksum(checkBytes))
return bytesToBuffer(frame)
}
function parseFrame(buffer) {
var bytes = bufferToBytes(buffer)
if (bytes.length < 5) return null
if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null
var len = bytes[2]
if (bytes.length < 5 + len) return null
var type = bytes[3]
var payload = bytes.slice(4, 4 + len)
var checksum = bytes[4 + len]
var expected = xorChecksum(bytes.slice(0, 4 + len))
if (checksum !== expected) return null
return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 }
}
function uint32ToBytes(value) {
return [
(value >> 24) & 0xFF,
(value >> 16) & 0xFF,
(value >> 8) & 0xFF,
value & 0xFF
]
}
function bytesToUint32(bytes, offset) {
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]
}
function hexToBytes(hex) {
var bytes = []
for (var i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16))
}
return bytes
}
function bytesToHex(bytes) {
var hex = ''
for (var i = 0; i < bytes.length; i++) {
hex += ('0' + bytes[i].toString(16)).slice(-2)
}
return hex.toUpperCase()
}
function findCharUuid(chars, shortUuid) {
for (var i = 0; i < chars.length; i++) {
if (chars[i].uuid.indexOf(shortUuid) !== -1) {
return chars[i].uuid
}
}
return null
}
function on(event, callback) {
if (!_listeners[event]) _listeners[event] = []
_listeners[event].push(callback)
}
function off(event, callback) {
if (!_listeners[event]) return
if (callback) {
_listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback })
} else {
_listeners[event] = []
}
}
function emit(event, data) {
if (!_listeners[event]) return
_listeners[event].forEach(function (cb) {
try { cb(data) } catch (e) { console.error('ble emit error:', e) }
})
}
function handleNotification(frame) {
switch (frame.type) {
case NOTIFY.STATUS_REPORT:
emit('status', parseStatusReport(frame.payload))
break
case NOTIFY.ACK:
var ack = parseAck(frame.payload)
emit('ack', ack)
if (_pendingAcks[ack.seq]) {
if (ack.error_code === 0) {
_pendingAcks[ack.seq].resolve(ack)
} else {
_pendingAcks[ack.seq].reject(ack)
}
delete _pendingAcks[ack.seq]
}
break
case NOTIFY.TREATMENT_COMPLETE:
emit('treatment_complete', parseTreatmentComplete(frame.payload))
break
case NOTIFY.EXCEPTION:
emit('exception', parseException(frame.payload))
break
case NOTIFY.BIND_SUCCESS:
emit('bind_result', { success: frame.payload[0] === 0x00 })
break
}
}
function parseStatusReport(payload) {
if (payload.length < 14) return null
return {
mode_state: payload[0],
region_mask: payload[1],
wavelength: payload[2],
brightness: payload[3],
remaining_ms: bytesToUint32(payload, 4),
error_code: payload[8],
command_seq: payload[9],
battery: payload[10],
temperature: payload[11],
bind_status: payload[12],
subscription: payload[13]
}
}
function parseAck(payload) {
return {
seq: payload[0],
error_code: payload.length > 1 ? payload[1] : 0,
error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN'
}
}
function parseTreatmentComplete(payload) {
return {
session_id: bytesToHex(payload.slice(0, 8)),
regions: payload[8],
total_duration_ms: bytesToUint32(payload, 9),
avg_pd: payload[13]
}
}
function parseException(payload) {
return {
error_code: payload[0],
error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN',
temperature: payload.length > 1 ? payload[1] : 0
}
}
function isConnected() {
return _connected && _deviceId !== null
}
function getDeviceId() {
return _deviceId
}
function startScan(callbacks) {
wx.openBluetoothAdapter({
success: function () {
wx.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
success: function () {
wx.offBluetoothDeviceFound()
wx.onBluetoothDeviceFound(function (res) {
var devices = res.devices || []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
var name = (d.name || '').toUpperCase()
var localName = (d.localName || '').toUpperCase()
if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) {
wx.stopBluetoothDevicesDiscovery({})
if (callbacks.onFound) callbacks.onFound(d)
connect(d.deviceId, callbacks)
return
}
}
})
},
fail: function () {
if (callbacks.onError) callbacks.onError({ msg: '扫描失败' })
}
})
},
fail: function () {
if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' })
}
})
}
function connect(deviceId, callbacks) {
_deviceId = deviceId
_chars = {}
wx.createBLEConnection({
deviceId: deviceId,
timeout: 10000,
success: function () {
_connected = true
discoverServices(deviceId, callbacks)
},
fail: function () {
_connected = false
if (callbacks.onError) callbacks.onError({ msg: '连接失败' })
}
})
}
function discoverServices(deviceId, callbacks) {
wx.getBLEDeviceServices({
deviceId: deviceId,
success: function (res) {
var services = res.services
var serviceMap = {}
for (var i = 0; i < services.length; i++) {
var uuid = services[i].uuid.toUpperCase()
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
serviceMap.deviceInfo = services[i].uuid
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
serviceMap.dataComm = services[i].uuid
} else if (uuid.indexOf(SERVICE.OTA) !== -1) {
serviceMap.ota = services[i].uuid
}
}
var tasks = []
if (serviceMap.deviceInfo) {
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
}
if (serviceMap.dataComm) {
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
}
Promise.all(tasks).then(function () {
subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () {
if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId })
})
})
},
fail: function () {
if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' })
}
})
}
function discoverChars(deviceId, serviceId, group) {
return new Promise(function (resolve) {
wx.getBLEDeviceCharacteristics({
deviceId: deviceId,
serviceId: serviceId,
success: function (res) {
var chars = res.characteristics || []
for (var i = 0; i < chars.length; i++) {
var c = chars[i]
var uuid = c.uuid.toUpperCase()
if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId }
}
resolve()
},
fail: function () { resolve() }
})
})
}
function subscribeToNotifications(deviceId, serviceId) {
return new Promise(function (resolve) {
if (!_chars.status) { resolve(); return }
wx.notifyBLECharacteristicValueChange({
deviceId: deviceId,
serviceId: _chars.status.serviceId,
characteristicId: _chars.status.uuid,
state: true,
success: function () {
wx.onBLECharacteristicValueChange(function (res) {
var frame = parseFrame(res.value)
if (frame) handleNotification(frame)
})
resolve()
},
fail: function () { resolve() }
})
})
}
function writeCommand(type, payload) {
return new Promise(function (resolve, reject) {
if (!_connected || !_deviceId) {
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
return
}
if (!_chars.command) {
reject({ error_code: 0xFF, error_msg: 'command characteristic not found' })
return
}
var seq = nextSeq()
var payloadWithSeq = (payload || []).concat([seq])
var buffer = buildFrame(type, payloadWithSeq)
_pendingAcks[seq] = { resolve: resolve, reject: reject }
setTimeout(function () {
if (_pendingAcks[seq]) {
_pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' })
delete _pendingAcks[seq]
}
}, 5000)
wx.writeBLECharacteristicValue({
deviceId: _deviceId,
serviceId: _chars.command.serviceId,
characteristicId: _chars.command.uuid,
value: buffer,
success: function () {},
fail: function () {
delete _pendingAcks[seq]
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
}
})
})
}
function writeCommandWithRetry(type, payload, maxRetries) {
if (maxRetries === undefined) maxRetries = 3
var attempt = 0
function tryOnce() {
attempt++
return writeCommand(type, payload).catch(function (err) {
if (err && err.error_code === 0x0C) {
return Promise.reject(err)
}
if (attempt >= maxRetries) {
return Promise.reject(err)
}
return new Promise(function (resolve) {
setTimeout(resolve, 500)
}).then(function () {
return tryOnce()
})
})
}
return tryOnce()
}
function readDeviceInfo() {
return new Promise(function (resolve, reject) {
if (!_connected || !_deviceId || !_chars.deviceInfo) {
reject({ msg: '设备未连接或特征值未就绪' })
return
}
wx.readBLECharacteristicValue({
deviceId: _deviceId,
serviceId: _chars.deviceInfo.serviceId,
characteristicId: _chars.deviceInfo.uuid,
success: function () {},
fail: function () { reject({ msg: '读取设备信息失败' }) }
})
var handler = function (res) {
if (res.characteristicId.toUpperCase().indexOf(CHAR.DEVICE_INFO) !== -1) {
wx.offBLECharacteristicValueChange(handler)
var bytes = bufferToBytes(res.value)
if (bytes.length >= 14) {
resolve({
hw_version: (bytes[0] << 8) | bytes[1],
fw_version: (bytes[2] << 8) | bytes[3],
device_type: (bytes[4] << 8) | bytes[5],
device_id: bytesToHex(bytes.slice(6, 14))
})
} else {
reject({ msg: '设备信息格式错误' })
}
}
}
wx.onBLECharacteristicValueChange(handler)
})
}
function setParams(options) {
var regionMask = options.region_mask || REGION.FULL_FACE
var wavelength = options.wavelength || WAVELENGTH.R
var brightness = options.brightness || 200
var durationMs = options.duration_ms || 600000
var mode = options.mode !== undefined ? options.mode : TREAT_MODE.NORMAL
var payload = [
regionMask,
wavelength,
brightness,
uint32ToBytes(durationMs),
mode
].reduce(function (a, b) {
return a.concat(Array.isArray(b) ? b : [b])
}, [])
return writeCommandWithRetry(CMD.SET_PARAMS, payload)
}
function startTreatment(regionMask) {
var mask = regionMask || REGION.FULL_FACE
return writeCommandWithRetry(CMD.START, [mask])
}
function stopTreatment() {
return writeCommandWithRetry(CMD.STOP, [])
}
function queryStatus() {
return writeCommandWithRetry(CMD.QUERY_STATUS, [])
}
function bindDevice(userId, bindToken) {
var userBytes = hexToBytes(userId)
var tokenBytes = hexToBytes(bindToken)
var ts = Math.floor(Date.now() / 1000)
var tsBytes = uint32ToBytes(ts)
var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
return writeCommandWithRetry(CMD.BIND, payload)
}
function unbindDevice(userId) {
var userBytes = hexToBytes(userId)
var payload = [0x02].concat(userBytes)
return writeCommandWithRetry(CMD.UNBIND, payload)
}
function stopScan() {
wx.stopBluetoothDevicesDiscovery({})
wx.offBluetoothDeviceFound()
}
function disconnect() {
_autoReconnect = false
_reconnecting = false
if (_deviceId) {
wx.closeBLEConnection({ deviceId: _deviceId })
_deviceId = null
}
_connected = false
_chars = {}
_pendingAcks = {}
_listeners = {}
wx.closeBluetoothAdapter({})
}
function attemptReconnect(deviceId, retriesLeft) {
wx.createBLEConnection({
deviceId: deviceId,
timeout: 10000,
success: function () {
_connected = true
_chars = {}
wx.getBLEDeviceServices({
deviceId: deviceId,
success: function (res) {
var services = res.services
var serviceMap = {}
for (var i = 0; i < services.length; i++) {
var uuid = services[i].uuid.toUpperCase()
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
serviceMap.deviceInfo = services[i].uuid
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
serviceMap.dataComm = services[i].uuid
}
}
var tasks = []
if (serviceMap.deviceInfo) {
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
}
if (serviceMap.dataComm) {
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
}
Promise.all(tasks).then(function () {
var serviceId = serviceMap.dataComm || null
return subscribeToNotifications(deviceId, serviceId)
}).then(function () {
_reconnecting = false
emit('reconnected', { deviceId: deviceId })
})
},
fail: function () {
// Service discovery failed, treat as reconnect failure
if (retriesLeft > 1) {
setTimeout(function () {
attemptReconnect(deviceId, retriesLeft - 1)
}, 2000)
} else {
_reconnecting = false
emit('reconnect_failed', { deviceId: deviceId })
}
}
})
},
fail: function () {
if (retriesLeft > 1) {
setTimeout(function () {
attemptReconnect(deviceId, retriesLeft - 1)
}, 2000)
} else {
_reconnecting = false
emit('reconnect_failed', { deviceId: deviceId })
}
}
})
}
function setAutoReconnect(enabled) {
_autoReconnect = enabled
}
wx.onBLEConnectionStateChange(function (res) {
if (!res.connected) {
_connected = false
emit('disconnected', { deviceId: res.deviceId })
if (_autoReconnect && !_reconnecting && _deviceId) {
_reconnecting = true
setTimeout(function () {
attemptReconnect(_deviceId, 3)
}, 2000)
}
}
})
function getRegionName(mask) {
var names = []
var bits = [
[0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'],
[0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周']
]
for (var i = 0; i < bits.length; i++) {
if (mask & bits[i][0]) names.push(bits[i][1])
}
return names
}
function getWavelengthName(code) {
var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' }
return map[code] || '未知'
}
function getModeStateName(code) {
var map = {
0x00: '空闲', 0x01: '扫描中', 0x02: '护理中',
0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中'
}
return map[code] || '未知'
}
module.exports = {
SERVICE: SERVICE,
CHAR: CHAR,
CMD: CMD,
NOTIFY: NOTIFY,
MODE_STATE: MODE_STATE,
WAVELENGTH: WAVELENGTH,
TREAT_MODE: TREAT_MODE,
REGION: REGION,
DEVICE_ERR: DEVICE_ERR,
isConnected: isConnected,
getDeviceId: getDeviceId,
startScan: startScan,
stopScan: stopScan,
connect: connect,
disconnect: disconnect,
setAutoReconnect: setAutoReconnect,
on: on,
off: off,
readDeviceInfo: readDeviceInfo,
setParams: setParams,
startTreatment: startTreatment,
stopTreatment: stopTreatment,
queryStatus: queryStatus,
bindDevice: bindDevice,
unbindDevice: unbindDevice,
buildFrame: buildFrame,
parseFrame: parseFrame,
parseStatusReport: parseStatusReport,
parseAck: parseAck,
parseTreatmentComplete: parseTreatmentComplete,
parseException: parseException,
getRegionName: getRegionName,
getWavelengthName: getWavelengthName,
getModeStateName: getModeStateName,
bufferToBytes: bufferToBytes,
bytesToBuffer: bytesToBuffer,
hexToBytes: hexToBytes,
bytesToHex: bytesToHex
}
+187
查看文件
@@ -0,0 +1,187 @@
// BLE commands: writing commands and high-level device operations
var protocol = require('./protocol')
var connection = require('./connection')
var _cmdSeq = 0
var _pendingAcks = {}
function nextSeq() {
_cmdSeq = (_cmdSeq + 1) % 256
return _cmdSeq
}
function getPendingAcks() {
return _pendingAcks
}
function clearPendingAcks() {
_pendingAcks = {}
}
// --- low-level write ---
function writeCommand(type, payload) {
return new Promise(function (resolve, reject) {
if (!connection.isConnected()) {
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
return
}
var chars = connection.getChars()
if (!chars.command) {
reject({ error_code: 0xFF, error_msg: 'command characteristic not found' })
return
}
var seq = nextSeq()
var payloadWithSeq = (payload || []).concat([seq])
var buffer = protocol.buildFrame(type, payloadWithSeq)
_pendingAcks[seq] = { resolve: resolve, reject: reject }
setTimeout(function () {
if (_pendingAcks[seq]) {
_pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' })
delete _pendingAcks[seq]
}
}, 5000)
var deviceId = connection.getDeviceId()
wx.writeBLECharacteristicValue({
deviceId: deviceId,
serviceId: chars.command.serviceId,
characteristicId: chars.command.uuid,
value: buffer,
success: function () {},
fail: function () {
delete _pendingAcks[seq]
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
}
})
})
}
function writeCommandWithRetry(type, payload, maxRetries) {
if (maxRetries === undefined) maxRetries = 3
var attempt = 0
function tryOnce() {
attempt++
return writeCommand(type, payload).catch(function (err) {
if (err && err.error_code === 0x0C) {
return Promise.reject(err)
}
if (attempt >= maxRetries) {
return Promise.reject(err)
}
return new Promise(function (resolve) {
setTimeout(resolve, 500)
}).then(function () {
return tryOnce()
})
})
}
return tryOnce()
}
// --- high-level commands ---
function readDeviceInfo() {
return new Promise(function (resolve, reject) {
var chars = connection.getChars()
var deviceId = connection.getDeviceId()
if (!connection.isConnected() || !chars.deviceInfo) {
reject({ msg: '设备未连接或特征值未就绪' })
return
}
wx.readBLECharacteristicValue({
deviceId: deviceId,
serviceId: chars.deviceInfo.serviceId,
characteristicId: chars.deviceInfo.uuid,
success: function () {},
fail: function () { reject({ msg: '读取设备信息失败' }) }
})
var handler = function (res) {
if (res.characteristicId.toUpperCase().indexOf(protocol.CHAR.DEVICE_INFO) !== -1) {
wx.offBLECharacteristicValueChange(handler)
var bytes = protocol.bufferToBytes(res.value)
if (bytes.length >= 14) {
resolve({
hw_version: (bytes[0] << 8) | bytes[1],
fw_version: (bytes[2] << 8) | bytes[3],
device_type: (bytes[4] << 8) | bytes[5],
device_id: protocol.bytesToHex(bytes.slice(6, 14))
})
} else {
reject({ msg: '设备信息格式错误' })
}
}
}
wx.onBLECharacteristicValueChange(handler)
})
}
function setParams(options) {
var regionMask = options.region_mask || protocol.REGION.FULL_FACE
var wavelength = options.wavelength || protocol.WAVELENGTH.R
var brightness = options.brightness || 200
var durationMs = options.duration_ms || 600000
var mode = options.mode !== undefined ? options.mode : protocol.TREAT_MODE.NORMAL
var payload = [
regionMask,
wavelength,
brightness,
protocol.uint32ToBytes(durationMs),
mode
].reduce(function (a, b) {
return a.concat(Array.isArray(b) ? b : [b])
}, [])
return writeCommandWithRetry(protocol.CMD.SET_PARAMS, payload)
}
function startTreatment(regionMask) {
var mask = regionMask || protocol.REGION.FULL_FACE
return writeCommandWithRetry(protocol.CMD.START, [mask])
}
function stopTreatment() {
return writeCommandWithRetry(protocol.CMD.STOP, [])
}
function queryStatus() {
return writeCommandWithRetry(protocol.CMD.QUERY_STATUS, [])
}
function bindDevice(userId, bindToken) {
var userBytes = protocol.hexToBytes(userId)
var tokenBytes = protocol.hexToBytes(bindToken)
var ts = Math.floor(Date.now() / 1000)
var tsBytes = protocol.uint32ToBytes(ts)
var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
return writeCommandWithRetry(protocol.CMD.BIND, payload)
}
function unbindDevice(userId) {
var userBytes = protocol.hexToBytes(userId)
var payload = [0x02].concat(userBytes)
return writeCommandWithRetry(protocol.CMD.UNBIND, payload)
}
module.exports = {
getPendingAcks: getPendingAcks,
clearPendingAcks: clearPendingAcks,
writeCommand: writeCommand,
writeCommandWithRetry: writeCommandWithRetry,
readDeviceInfo: readDeviceInfo,
setParams: setParams,
startTreatment: startTreatment,
stopTreatment: stopTreatment,
queryStatus: queryStatus,
bindDevice: bindDevice,
unbindDevice: unbindDevice
}
+341
查看文件
@@ -0,0 +1,341 @@
// BLE connection: scan, connect, disconnect, reconnect logic
var protocol = require('./protocol')
var SERVICE = protocol.SERVICE
var CHAR = protocol.CHAR
// Shared state — accessed by commands.js via getters/setters
var _deviceId = null
var _connected = false
var _chars = {}
var _autoReconnect = true
var _reconnecting = false
// Event emitter — shared across modules
var _listeners = {}
function on(event, callback) {
if (!_listeners[event]) _listeners[event] = []
_listeners[event].push(callback)
}
function off(event, callback) {
if (!_listeners[event]) return
if (callback) {
_listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback })
} else {
_listeners[event] = []
}
}
function emit(event, data) {
if (!_listeners[event]) return
_listeners[event].forEach(function (cb) {
try { cb(data) } catch (e) { console.error('ble emit error:', e) }
})
}
// --- state accessors (used by commands.js) ---
function getDeviceId() {
return _deviceId
}
function isConnected() {
return _connected && _deviceId !== null
}
function getChars() {
return _chars
}
// --- notification handling ---
function handleNotification(frame) {
var pendingAcks = require('./commands').getPendingAcks()
switch (frame.type) {
case protocol.NOTIFY.STATUS_REPORT:
emit('status', protocol.parseStatusReport(frame.payload))
break
case protocol.NOTIFY.ACK:
var ack = protocol.parseAck(frame.payload)
emit('ack', ack)
if (pendingAcks[ack.seq]) {
if (ack.error_code === 0) {
pendingAcks[ack.seq].resolve(ack)
} else {
pendingAcks[ack.seq].reject(ack)
}
delete pendingAcks[ack.seq]
}
break
case protocol.NOTIFY.TREATMENT_COMPLETE:
emit('treatment_complete', protocol.parseTreatmentComplete(frame.payload))
break
case protocol.NOTIFY.EXCEPTION:
emit('exception', protocol.parseException(frame.payload))
break
case protocol.NOTIFY.BIND_SUCCESS:
emit('bind_result', { success: frame.payload[0] === 0x00 })
break
}
}
// --- service/characteristic discovery ---
function discoverChars(deviceId, serviceId, group) {
return new Promise(function (resolve) {
wx.getBLEDeviceCharacteristics({
deviceId: deviceId,
serviceId: serviceId,
success: function (res) {
var chars = res.characteristics || []
for (var i = 0; i < chars.length; i++) {
var c = chars[i]
var uuid = c.uuid.toUpperCase()
if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId }
if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId }
}
resolve()
},
fail: function () { resolve() }
})
})
}
function subscribeToNotifications(deviceId, serviceId) {
return new Promise(function (resolve) {
if (!_chars.status) { resolve(); return }
wx.notifyBLECharacteristicValueChange({
deviceId: deviceId,
serviceId: _chars.status.serviceId,
characteristicId: _chars.status.uuid,
state: true,
success: function () {
wx.onBLECharacteristicValueChange(function (res) {
var frame = protocol.parseFrame(res.value)
if (frame) handleNotification(frame)
})
resolve()
},
fail: function () { resolve() }
})
})
}
function discoverServices(deviceId, callbacks) {
wx.getBLEDeviceServices({
deviceId: deviceId,
success: function (res) {
var services = res.services
var serviceMap = {}
for (var i = 0; i < services.length; i++) {
var uuid = services[i].uuid.toUpperCase()
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
serviceMap.deviceInfo = services[i].uuid
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
serviceMap.dataComm = services[i].uuid
} else if (uuid.indexOf(SERVICE.OTA) !== -1) {
serviceMap.ota = services[i].uuid
}
}
var tasks = []
if (serviceMap.deviceInfo) {
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
}
if (serviceMap.dataComm) {
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
}
Promise.all(tasks).then(function () {
subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () {
if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId })
})
})
},
fail: function () {
if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' })
}
})
}
// --- scan & connect ---
function startScan(callbacks) {
wx.openBluetoothAdapter({
success: function () {
wx.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
success: function () {
wx.offBluetoothDeviceFound()
wx.onBluetoothDeviceFound(function (res) {
var devices = res.devices || []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
var name = (d.name || '').toUpperCase()
var localName = (d.localName || '').toUpperCase()
if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) {
wx.stopBluetoothDevicesDiscovery({})
if (callbacks.onFound) callbacks.onFound(d)
connect(d.deviceId, callbacks)
return
}
}
})
},
fail: function () {
if (callbacks.onError) callbacks.onError({ msg: '扫描失败' })
}
})
},
fail: function () {
if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' })
}
})
}
function stopScan() {
wx.stopBluetoothDevicesDiscovery({})
wx.offBluetoothDeviceFound()
}
function connect(deviceId, callbacks) {
_deviceId = deviceId
_chars = {}
wx.createBLEConnection({
deviceId: deviceId,
timeout: 10000,
success: function () {
_connected = true
discoverServices(deviceId, callbacks)
},
fail: function () {
_connected = false
if (callbacks.onError) callbacks.onError({ msg: '连接失败' })
}
})
}
function disconnect() {
_autoReconnect = false
_reconnecting = false
if (_deviceId) {
wx.closeBLEConnection({ deviceId: _deviceId })
_deviceId = null
}
_connected = false
_chars = {}
var commands = require('./commands')
commands.clearPendingAcks()
_listeners = {}
wx.closeBluetoothAdapter({})
}
// --- reconnect ---
function attemptReconnect(deviceId, retriesLeft) {
wx.createBLEConnection({
deviceId: deviceId,
timeout: 10000,
success: function () {
_connected = true
_chars = {}
wx.getBLEDeviceServices({
deviceId: deviceId,
success: function (res) {
var services = res.services
var serviceMap = {}
for (var i = 0; i < services.length; i++) {
var uuid = services[i].uuid.toUpperCase()
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
serviceMap.deviceInfo = services[i].uuid
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
serviceMap.dataComm = services[i].uuid
}
}
var tasks = []
if (serviceMap.deviceInfo) {
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
}
if (serviceMap.dataComm) {
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
}
Promise.all(tasks).then(function () {
var serviceId = serviceMap.dataComm || null
return subscribeToNotifications(deviceId, serviceId)
}).then(function () {
_reconnecting = false
emit('reconnected', { deviceId: deviceId })
})
},
fail: function () {
if (retriesLeft > 1) {
setTimeout(function () {
attemptReconnect(deviceId, retriesLeft - 1)
}, 2000)
} else {
_reconnecting = false
emit('reconnect_failed', { deviceId: deviceId })
}
}
})
},
fail: function () {
if (retriesLeft > 1) {
setTimeout(function () {
attemptReconnect(deviceId, retriesLeft - 1)
}, 2000)
} else {
_reconnecting = false
emit('reconnect_failed', { deviceId: deviceId })
}
}
})
}
function setAutoReconnect(enabled) {
_autoReconnect = enabled
}
// --- BLE connection state change listener ---
wx.onBLEConnectionStateChange(function (res) {
if (!res.connected) {
_connected = false
emit('disconnected', { deviceId: res.deviceId })
if (_autoReconnect && !_reconnecting && _deviceId) {
_reconnecting = true
setTimeout(function () {
attemptReconnect(_deviceId, 3)
}, 2000)
}
}
})
module.exports = {
on: on,
off: off,
emit: emit,
getDeviceId: getDeviceId,
isConnected: isConnected,
getChars: getChars,
startScan: startScan,
stopScan: stopScan,
connect: connect,
disconnect: disconnect,
attemptReconnect: attemptReconnect,
setAutoReconnect: setAutoReconnect
}
+59
查看文件
@@ -0,0 +1,59 @@
// BLE service barrel — re-exports a unified API
// Usage: var ble = require('../../services/ble')
var protocol = require('./protocol')
var connection = require('./connection')
var commands = require('./commands')
module.exports = {
// Constants
SERVICE: protocol.SERVICE,
CHAR: protocol.CHAR,
CMD: protocol.CMD,
NOTIFY: protocol.NOTIFY,
MODE_STATE: protocol.MODE_STATE,
WAVELENGTH: protocol.WAVELENGTH,
TREAT_MODE: protocol.TREAT_MODE,
REGION: protocol.REGION,
DEVICE_ERR: protocol.DEVICE_ERR,
// Connection
isConnected: connection.isConnected,
getDeviceId: connection.getDeviceId,
startScan: connection.startScan,
stopScan: connection.stopScan,
connect: connection.connect,
disconnect: connection.disconnect,
setAutoReconnect: connection.setAutoReconnect,
on: connection.on,
off: connection.off,
// Commands
readDeviceInfo: commands.readDeviceInfo,
setParams: commands.setParams,
startTreatment: commands.startTreatment,
stopTreatment: commands.stopTreatment,
queryStatus: commands.queryStatus,
bindDevice: commands.bindDevice,
unbindDevice: commands.unbindDevice,
// Protocol utilities
buildFrame: protocol.buildFrame,
parseFrame: protocol.parseFrame,
parseStatusReport: protocol.parseStatusReport,
parseAck: protocol.parseAck,
parseTreatmentComplete: protocol.parseTreatmentComplete,
parseException: protocol.parseException,
// Display helpers
REGION_NAMES: protocol.REGION_NAMES,
getRegionName: protocol.getRegionName,
getWavelengthName: protocol.getWavelengthName,
getModeStateName: protocol.getModeStateName,
// Byte utilities
bufferToBytes: protocol.bufferToBytes,
bytesToBuffer: protocol.bytesToBuffer,
hexToBytes: protocol.hexToBytes,
bytesToHex: protocol.bytesToHex
}
+272
查看文件
@@ -0,0 +1,272 @@
// BLE protocol: frame encoding/decoding, checksum, constants
var SERVICE = {
DEVICE_INFO: 'FFE0',
DATA_COMM: 'FFE1',
OTA: 'FFE2'
}
var CHAR = {
DEVICE_INFO: 'FFE3',
COMMAND: 'FFE4',
STATUS: 'FFE5',
BOND_INFO: 'FFE6',
OTA_CONTROL: 'FFE7',
OTA_DATA: 'FFE8',
OTA_STATUS: 'FFE9'
}
var CMD = {
SET_PARAMS: 0x01,
START: 0x02,
STOP: 0x03,
QUERY_STATUS: 0x04,
BIND: 0x05,
UNBIND: 0x06
}
var NOTIFY = {
STATUS_REPORT: 0x21,
ACK: 0x22,
TREATMENT_COMPLETE: 0x31,
EXCEPTION: 0x32,
BIND_SUCCESS: 0x33
}
var MODE_STATE = {
IDLE: 0x00,
SCANNING: 0x01,
ACTIVE: 0x02,
PAUSED: 0x03,
COMPLETED: 0x04,
ERROR: 0x05,
OTA: 0x06
}
var WAVELENGTH = {
IR: 1,
R: 2,
UV: 3,
Y: 4
}
var TREAT_MODE = {
NORMAL: 0,
SMART: 1
}
var REGION = {
LEFT_CHEEK: 0x01,
RIGHT_CHEEK: 0x02,
FOREHEAD: 0x04,
CHIN: 0x08,
NOSE: 0x10,
LEFT_EYE: 0x20,
RIGHT_EYE: 0x40,
FULL_FACE: 0x7F
}
var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
var DEVICE_ERR = {
0x00: 'SUCCESS',
0x01: 'ERR_REGION_INVALID',
0x02: 'ERR_REGION_EMPTY',
0x03: 'ERR_BRIGHTNESS_INVALID',
0x04: 'ERR_DURATION_INVALID',
0x05: 'ERR_NOT_BOUND',
0x06: 'ERR_NO_SUBSCRIPTION',
0x07: 'ERR_TEMP_HIGH',
0x08: 'ERR_BATTERY_LOW',
0x09: 'ERR_ALREADY_RUNNING',
0x0A: 'ERR_NOT_RUNNING',
0x0B: 'ERR_OTA_FAILED',
0x0C: 'ERR_BLE_DISCONNECTED'
}
// --- byte utilities ---
function bufferToBytes(buffer) {
var arr = new Uint8Array(buffer)
var bytes = []
for (var i = 0; i < arr.length; i++) {
bytes.push(arr[i])
}
return bytes
}
function bytesToBuffer(bytes) {
var buffer = new ArrayBuffer(bytes.length)
var view = new Uint8Array(buffer)
for (var i = 0; i < bytes.length; i++) {
view[i] = bytes[i]
}
return buffer
}
function xorChecksum(bytes) {
var result = 0
for (var i = 0; i < bytes.length; i++) {
result ^= bytes[i]
}
return result
}
function uint32ToBytes(value) {
return [
(value >> 24) & 0xFF,
(value >> 16) & 0xFF,
(value >> 8) & 0xFF,
value & 0xFF
]
}
function bytesToUint32(bytes, offset) {
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]
}
function hexToBytes(hex) {
var bytes = []
for (var i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16))
}
return bytes
}
function bytesToHex(bytes) {
var hex = ''
for (var i = 0; i < bytes.length; i++) {
hex += ('0' + bytes[i].toString(16)).slice(-2)
}
return hex.toUpperCase()
}
// --- frame encoding/decoding ---
function buildFrame(type, payload) {
var len = payload ? payload.length : 0
var frame = [0xAA, 0x55, len, type]
if (payload && payload.length > 0) {
frame = frame.concat(payload)
}
var checkBytes = frame.slice(0)
frame.push(xorChecksum(checkBytes))
return bytesToBuffer(frame)
}
function parseFrame(buffer) {
var bytes = bufferToBytes(buffer)
if (bytes.length < 5) return null
if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null
var len = bytes[2]
if (bytes.length < 5 + len) return null
var type = bytes[3]
var payload = bytes.slice(4, 4 + len)
var checksum = bytes[4 + len]
var expected = xorChecksum(bytes.slice(0, 4 + len))
if (checksum !== expected) return null
return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 }
}
// --- notification parsing ---
function parseStatusReport(payload) {
if (payload.length < 14) return null
return {
mode_state: payload[0],
region_mask: payload[1],
wavelength: payload[2],
brightness: payload[3],
remaining_ms: bytesToUint32(payload, 4),
error_code: payload[8],
command_seq: payload[9],
battery: payload[10],
temperature: payload[11],
bind_status: payload[12],
subscription: payload[13]
}
}
function parseAck(payload) {
return {
seq: payload[0],
error_code: payload.length > 1 ? payload[1] : 0,
error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN'
}
}
function parseTreatmentComplete(payload) {
return {
session_id: bytesToHex(payload.slice(0, 8)),
regions: payload[8],
total_duration_ms: bytesToUint32(payload, 9),
avg_pd: payload[13]
}
}
function parseException(payload) {
return {
error_code: payload[0],
error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN',
temperature: payload.length > 1 ? payload[1] : 0
}
}
// --- display helpers ---
function getRegionName(mask) {
var names = []
var bits = [
[0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'],
[0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周']
]
for (var i = 0; i < bits.length; i++) {
if (mask & bits[i][0]) names.push(bits[i][1])
}
return names
}
function getWavelengthName(code) {
var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' }
return map[code] || '未知'
}
function getModeStateName(code) {
var map = {
0x00: '空闲', 0x01: '扫描中', 0x02: '护理中',
0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中'
}
return map[code] || '未知'
}
module.exports = {
SERVICE: SERVICE,
CHAR: CHAR,
CMD: CMD,
NOTIFY: NOTIFY,
MODE_STATE: MODE_STATE,
WAVELENGTH: WAVELENGTH,
TREAT_MODE: TREAT_MODE,
REGION: REGION,
REGION_NAMES: REGION_NAMES,
DEVICE_ERR: DEVICE_ERR,
bufferToBytes: bufferToBytes,
bytesToBuffer: bytesToBuffer,
xorChecksum: xorChecksum,
uint32ToBytes: uint32ToBytes,
bytesToUint32: bytesToUint32,
hexToBytes: hexToBytes,
bytesToHex: bytesToHex,
buildFrame: buildFrame,
parseFrame: parseFrame,
parseStatusReport: parseStatusReport,
parseAck: parseAck,
parseTreatmentComplete: parseTreatmentComplete,
parseException: parseException,
getRegionName: getRegionName,
getWavelengthName: getWavelengthName,
getModeStateName: getModeStateName
}
+32
查看文件
@@ -0,0 +1,32 @@
var http = require('./request')
module.exports = {
// Auth
login: function (code) { return http.post('/api/v1/auth/login', { code: code }) },
getPhone: function (code) { return http.post('/api/v1/user/phone', { code: code }) },
// User
getProfile: function () { return http.get('/api/v1/user/profile') },
updateProfile: function (data) { return http.put('/api/v1/user/profile', data) },
// Device
getDevices: function () { return http.get('/api/v1/device/list') },
bindDevice: function (deviceId) { return http.post('/api/v1/device/bind', { device_id: deviceId }) },
confirmBind: function (deviceId, token) { return http.post('/api/v1/device/bind/confirm', { device_id: deviceId, bind_token: token }) },
mockBind: function (deviceId) { return http.post('/api/v1/device/mock-bind', { device_id: deviceId }) },
unbindDevice: function (deviceId) { return http.post('/api/v1/device/unbind', { device_id: deviceId }) },
// Subscription
getSubscription: function () { return http.get('/api/v1/subscription') },
activateTrial: function () { return http.post('/api/v1/subscription/trial') },
purchase: function (plan) { return http.post('/api/v1/subscription/purchase', { plan: plan }) },
// Treatment
getRecords: function (params) { return http.get('/api/v1/treatment/history', params) },
syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) },
getRecordDetail: function (id) { return http.get('/api/v1/treatment/' + id) },
// Device commands
getPendingCommands: function (deviceId) { return http.get('/api/v1/device/command/pending', { device_id: deviceId }) },
reportCommandResult: function (data) { return http.post('/api/v1/device/command/result', data) }
}
+25
查看文件
@@ -0,0 +1,25 @@
var app = getApp()
/** Get status bar height for custom navigation pages */
function getStatusBarHeight() {
return app.globalData.statusBarHeight || 44
}
/** Standard back navigation with fallback */
function navigateBack(fallbackUrl) {
wx.navigateBack({
fail: function () {
if (fallbackUrl) {
wx.reLaunch({ url: fallbackUrl })
}
}
})
}
/** Check if dev mode is enabled */
function isDevMode() {
var config = require('../config/env')
return config.__DEV__ || false
}
module.exports = { getStatusBarHeight: getStatusBarHeight, navigateBack: navigateBack, isDevMode: isDevMode }
+874
查看文件
文件差异内容过多而无法显示 加载差异
+2 -2
查看文件
@@ -12,8 +12,8 @@
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.14.7",
"dotenv": "^16.4.5",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
},
"devDependencies": {}
}
}
+3 -22
查看文件
@@ -1,27 +1,8 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') })
const http = require('http')
const config = require('../src/config')
const { handle } = require('../src/app')
const app = 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)
app.listen(config.port, () => {
console.log('Server listening on http://localhost:' + config.port)
})
+28 -27
查看文件
@@ -1,34 +1,35 @@
const Router = require('./lib/router')
const { createContext } = require('./lib/request')
const { ok, fail, http } = require('./lib/response')
const express = require('express')
const { ok, fail } = require('./lib/response')
const { authMiddleware } = require('./middleware/auth')
const router = new Router()
const app = express()
require('./routes/auth')(router)
require('./routes/user')(router)
require('./routes/device')(router)
require('./routes/subscription')(router)
require('./routes/treatment')(router)
require('./routes/admin')(router)
require('./routes/firmware')(router)
app.use(express.json())
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
})
async function handle(event) {
const ctx = createContext(event || {})
if (ctx.method === 'OPTIONS') return http(204, {})
if (ctx.path === '/health') return http(200, ok({ status: 'ok' }))
app.use(authMiddleware)
const match = router.match(ctx.method, ctx.path)
if (!match) return http(404, fail(404, 'not_found'))
app.get('/health', (req, res) => res.json(ok({ status: 'ok' })))
ctx.params = match.params
app.use('/api/v1', require('./routes/auth'))
app.use('/api/v1', require('./routes/user'))
app.use('/api/v1', require('./routes/device'))
app.use('/api/v1', require('./routes/subscription'))
app.use('/api/v1', require('./routes/treatment'))
app.use('/api/v1/admin', require('./routes/admin'))
app.use('/api/v1', require('./routes/firmware'))
try {
const body = await match.handler(ctx)
return http(200, body)
} catch (err) {
console.error('[ERROR]', ctx.method, ctx.path, err.code || '', err.sqlMessage || err.message, err.stack)
return http(500, fail(3001, 'server_error'))
}
}
app.use((req, res) => res.status(404).json(fail(404, 'not_found')))
module.exports = { handle }
app.use((err, req, res, _next) => {
console.error('[ERROR]', req.method, req.path, err.code || '', err.sqlMessage || err.message, err.stack)
res.status(500).json(fail(3001, 'server_error'))
})
module.exports = app
+93
查看文件
@@ -0,0 +1,93 @@
const { query, one } = require('../lib/db')
/**
* Find admin account by username (active only)
* @param {string} username
* @returns {Promise<Object|null>} admin row or null
*/
async function findByUsername(username) {
return one(
'SELECT * FROM admin_accounts WHERE username = :username AND status = 1',
{ username }
)
}
/**
* Find admin account by ID (active only)
* @param {number} adminId
* @returns {Promise<Object|null>} admin row or null
*/
async function findById(adminId) {
return one(
'SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1',
{ admin_id: adminId }
)
}
/**
* Update admin password hash and clear legacy salt
* @param {number} adminId
* @param {string} passwordHash - bcrypt hash
* @returns {Promise<Array>} query result
*/
async function updatePassword(adminId, passwordHash) {
return query(
'UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id',
{ password_hash: passwordHash, password_salt: '', admin_id: adminId }
)
}
/**
* Auto-migrate password from legacy SHA-256 to bcrypt
* @param {number} adminId
* @param {string} newHash - bcrypt hash
* @returns {Promise<Array>} query result
*/
async function migratePassword(adminId, newHash) {
return updatePassword(adminId, newHash)
}
/**
* Get dashboard aggregate counts (devices, users, treatments, active subscriptions)
* @returns {Promise<{device_count: number, user_count: number, treatment_count: number, subscription_count: number}>}
*/
async function getDashboardCounts() {
const rows = await Promise.all([
query('SELECT COUNT(*) AS total FROM devices', {}),
query('SELECT COUNT(*) AS total FROM users', {}),
query('SELECT COUNT(*) AS total FROM treatment_records', {}),
query('SELECT COUNT(*) AS total FROM subscriptions WHERE status = 1 AND expire_time > NOW()', {})
])
return {
device_count: rows[0][0].total,
user_count: rows[1][0].total,
treatment_count: rows[2][0].total,
subscription_count: rows[3][0].total
}
}
/**
* Get subscription breakdown stats (monthly/yearly/trial counts, monthly revenue)
* @returns {Promise<Object>} { monthly_count, yearly_count, trial_count, monthly_revenue }
*/
async function getSubscriptionStats() {
const rows = await query(
'SELECT ' +
"SUM(CASE WHEN plan = 'monthly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, " +
"SUM(CASE WHEN plan = 'yearly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, " +
"SUM(CASE WHEN plan = 'trial' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, " +
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
'FROM subscriptions',
{}
)
return rows[0] || {}
}
module.exports = {
findByUsername,
findById,
updatePassword,
migratePassword,
getDashboardCounts,
getSubscriptionStats
}
+203
查看文件
@@ -0,0 +1,203 @@
const { query, one, transaction } = require('../lib/db')
/**
* Find active binding for a user (bind_status=1)
* Uses transaction connection when provided
* @param {number} userId
* @param {Object} [conn] - optional transaction connection
* @returns {Promise<Object|null>}
*/
async function findActiveByUser(userId, conn) {
if (conn) {
const [rows] = await conn.execute(
'SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1',
[userId]
)
return rows[0] || null
}
return one(
'SELECT device_id FROM bindings WHERE user_id = :user_id AND bind_status = 1 LIMIT 1',
{ user_id: userId }
)
}
/**
* Check if a device exists and is not disabled (status <> 4)
* Uses transaction connection when provided
* @param {string} deviceId
* @param {Object} [conn] - optional transaction connection
* @returns {Promise<Object|null>}
*/
async function findDeviceExists(deviceId, conn) {
if (conn) {
const [rows] = await conn.execute(
'SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1',
[deviceId]
)
return rows[0] || null
}
return one(
'SELECT * FROM devices WHERE device_id = :device_id AND status <> 4 LIMIT 1',
{ device_id: deviceId }
)
}
/**
* Create a pending binding request with 10-minute expiry
* @param {number} userId
* @param {string} deviceId
* @param {string} bindToken
* @param {Object} [conn] - optional transaction connection
* @returns {Promise<void>}
*/
async function createPending(userId, deviceId, bindToken, conn) {
if (conn) {
await conn.execute(
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())',
[userId, deviceId, bindToken]
)
return
}
await query(
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (:user_id, :device_id, :bind_token, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())',
{ user_id: userId, device_id: deviceId, bind_token: bindToken }
)
}
/**
* Confirm a pending binding by token, set bind_status=1.
* Also ensures trial subscription if none active.
* @param {number} userId
* @param {string} deviceId
* @param {string} bindToken
* @returns {Promise<boolean>} true if confirmed, false if token invalid/expired
*/
async function confirmBind(userId, deviceId, bindToken) {
return transaction(async conn => {
const [rows] = await conn.execute(
'SELECT binding_id FROM bindings WHERE user_id = ? AND device_id = ? AND bind_token = ? AND bind_status = 3 AND bind_expires > NOW() LIMIT 1',
[userId, deviceId, bindToken]
)
if (rows.length === 0) return false
await conn.execute(
'UPDATE bindings SET bind_status = 1, bind_time = NOW() WHERE binding_id = ?',
[rows[0].binding_id]
)
// Ensure trial subscription
const [subs] = await conn.execute(
'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1',
[userId]
)
if (subs.length === 0) {
await conn.execute(
"INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, 'trial', 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))",
[userId]
)
}
return true
})
}
/**
* Mock bind (dev/test only): directly bind with status=1, auto-trial
* @param {number} userId
* @param {string} deviceId
* @returns {Promise<{success?: boolean, duplicated?: boolean, device_id?: string, invalid?: boolean}>}
*/
async function mockBind(userId, deviceId) {
return transaction(async conn => {
const [active] = await conn.execute(
'SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1',
[userId]
)
if (active.length > 0) return { duplicated: true, device_id: active[0].device_id }
const [devices] = await conn.execute(
'SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1',
[deviceId]
)
if (devices.length === 0) return { invalid: true }
await conn.execute(
'UPDATE bindings SET bind_status = 2 WHERE user_id = ? AND bind_status = 3',
[userId]
)
await conn.execute(
"INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, 'mock', NOW(), 1, NOW())",
[userId, deviceId]
)
// Ensure trial
const [subs] = await conn.execute(
'SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1',
[userId]
)
if (subs.length === 0) {
await conn.execute(
"INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, 'trial', 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))",
[userId]
)
}
return { success: true }
})
}
/**
* Unbind device(s) for a user
* @param {number} userId
* @param {string|null} deviceId - specific device or null for all active bindings
* @returns {Promise<Array>} query result
*/
async function unbindByUser(userId, deviceId) {
return query(
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = :user_id AND bind_status = 1 AND (:device_id IS NULL OR device_id = :device_id)',
{ user_id: userId, device_id: deviceId }
)
}
/**
* Get binding history for a device with user nicknames
* @param {string} deviceId
* @returns {Promise<Array>}
*/
async function getHistoryByDevice(deviceId) {
return query(
'SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC',
{ device_id: deviceId }
)
}
/**
* Check if a user has an active binding to a specific device
* @param {number} userId
* @param {string} deviceId
* @returns {Promise<Object|null>}
*/
async function findUserDeviceBinding(userId, deviceId) {
return one(
'SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1',
{ user_id: userId, device_id: deviceId }
)
}
/**
* Count active bindings for a user
* @param {number} userId
* @returns {Promise<number>}
*/
async function countActiveByUser(userId) {
const rows = await query(
'SELECT COUNT(*) AS total FROM bindings WHERE user_id = :user_id AND bind_status = 1',
{ user_id: userId }
)
return rows[0].total
}
module.exports = {
findActiveByUser,
findDeviceExists,
createPending,
confirmBind,
mockBind,
unbindByUser,
getHistoryByDevice,
findUserDeviceBinding,
countActiveByUser
}
+120
查看文件
@@ -0,0 +1,120 @@
const { query, one, limitClause } = require('../lib/db')
/**
* Create a device command
* @param {string} deviceId
* @param {number} adminId
* @param {number} opcode
* @param {Object} payload - will be JSON-stringified
* @returns {Promise<Array>} query result
*/
async function create(deviceId, adminId, opcode, payload) {
return query(
'INSERT INTO device_commands (device_id, admin_id, opcode, payload_json, status) VALUES (:device_id, :admin_id, :opcode, :payload_json, 1)',
{
device_id: deviceId,
admin_id: adminId,
opcode,
payload_json: JSON.stringify(payload)
}
)
}
/**
* List commands for a device with pagination
* @param {string} deviceId
* @param {Object} opts
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function listByDevice(deviceId, { pageSize, offset }) {
const total = await query(
'SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id',
{ device_id: deviceId }
)
const records = await query(
'SELECT command_id, device_id, admin_id, opcode, payload_json, status, created_at, pulled_at, finished_at, result_json FROM device_commands WHERE device_id = :device_id ORDER BY created_at DESC' + limitClause(pageSize, offset),
{ device_id: deviceId }
)
return { records, total: total[0].total }
}
/**
* Count commands for a device
* @param {string} deviceId
* @returns {Promise<number>}
*/
async function countByDevice(deviceId) {
const rows = await query(
'SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id',
{ device_id: deviceId }
)
return rows[0].total
}
/**
* Get pending commands for a device (status=1), ordered by creation time
* @param {string} deviceId
* @returns {Promise<Array>} commands with command_id, opcode, payload_json
*/
async function getPending(deviceId) {
return query(
'SELECT command_id, opcode, payload_json FROM device_commands WHERE device_id = :device_id AND status = 1 ORDER BY created_at ASC LIMIT 10',
{ device_id: deviceId }
)
}
/**
* Mark commands as pulled (status=2)
* @param {number[]} commandIds - array of command IDs
* @returns {Promise<Array>} query result
*/
async function markPulled(commandIds) {
if (!commandIds || commandIds.length === 0) return []
return query(
'UPDATE device_commands SET status = 2, pulled_at = NOW() WHERE command_id IN (' + commandIds.map(Number).join(',') + ')',
{}
)
}
/**
* Finish a command execution (set status=3 success or status=4 failure)
* @param {number} commandId
* @param {boolean} success
* @param {Object} result - result payload to store as JSON
* @returns {Promise<Array>} query result
*/
async function finish(commandId, success, result) {
return query(
'UPDATE device_commands SET status = :status, finished_at = NOW(), result_json = :result_json WHERE command_id = :command_id',
{
command_id: commandId,
status: success ? 3 : 4,
result_json: JSON.stringify(result)
}
)
}
/**
* Find a command by ID that belongs to a device bound to a specific user
* @param {number} commandId
* @param {number} userId
* @returns {Promise<Object|null>}
*/
async function findByIdForUser(commandId, userId) {
return one(
'SELECT dc.command_id FROM device_commands dc JOIN bindings b ON b.device_id = dc.device_id AND b.user_id = :user_id AND b.bind_status = 1 WHERE dc.command_id = :command_id',
{ user_id: userId, command_id: commandId }
)
}
module.exports = {
create,
listByDevice,
countByDevice,
getPending,
markPulled,
finish,
findByIdForUser
}
+30
查看文件
@@ -0,0 +1,30 @@
const { query } = require('../lib/db')
/**
* Insert a device event record
* @param {Object} event
* @param {string} event.device_id
* @param {number} event.user_id
* @param {string} [event.event_type] - defaults to 'device_error'
* @param {string|null} [event.error_code]
* @param {number|null} [event.temperature]
* @param {Object} event.payload - raw payload, will be JSON-stringified
* @returns {Promise<Array>} query result
*/
async function create(event) {
return query(
'INSERT INTO device_events (device_id, user_id, event_type, error_code, temperature, payload_json) VALUES (:device_id, :user_id, :event_type, :error_code, :temperature, :payload_json)',
{
device_id: event.device_id,
user_id: event.user_id,
event_type: event.event_type || 'device_error',
error_code: event.error_code || null,
temperature: event.temperature || null,
payload_json: JSON.stringify(event.payload || {})
}
)
}
module.exports = {
create
}
+172
查看文件
@@ -0,0 +1,172 @@
const { query, one, limitClause } = require('../lib/db')
/**
* List devices with pagination and optional keyword search
* @param {Object} opts
* @param {string} [opts.keyword] - search device_id or device_name
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function list({ keyword, pageSize, offset }) {
let where = ''
const params = {}
if (keyword) {
where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw'
params.kw = '%' + keyword + '%'
}
const total = await query('SELECT COUNT(*) AS total FROM devices d' + where, params)
const records = await query(
'SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1' +
where + ' ORDER BY d.created_at DESC' + limitClause(pageSize, offset),
params
)
return { records, total: total[0].total }
}
/**
* Count devices matching optional keyword
* @param {Object} opts
* @param {string} [opts.keyword]
* @returns {Promise<number>}
*/
async function count({ keyword }) {
let where = ''
const params = {}
if (keyword) {
where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw'
params.kw = '%' + keyword + '%'
}
const rows = await query('SELECT COUNT(*) AS total FROM devices d' + where, params)
return rows[0].total
}
/**
* Find a single device by ID with bound user info
* @param {string} deviceId
* @returns {Promise<Object|null>}
*/
async function findById(deviceId) {
return one(
'SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1 WHERE d.device_id = :device_id',
{ device_id: deviceId }
)
}
/**
* Find device by ID with binding history and recent treatments
* @param {string} deviceId
* @returns {Promise<{device: Object|null, binding_history: Array, recent_treatments: Array}>}
*/
async function findByIdWithHistory(deviceId) {
const device = await findById(deviceId)
if (!device) return { device: null, binding_history: [], recent_treatments: [] }
const bindingHistory = await query(
'SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC',
{ device_id: deviceId }
)
const recentTreatments = await query(
'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id WHERE r.device_id = :device_id ORDER BY r.created_at DESC LIMIT 5',
{ device_id: deviceId }
)
return { device, binding_history: bindingHistory, recent_treatments: recentTreatments }
}
/**
* Create or update a device (upsert)
* @param {Object} device
* @param {string} device.device_id
* @param {string} [device.product_id]
* @param {string} [device.device_secret]
* @param {string} [device.device_name]
* @param {string} [device.firmware_version]
* @returns {Promise<Array>} query result
*/
async function create(device) {
return query(
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
{
device_id: device.device_id,
product_id: device.product_id || 'HOX_LIGHT_MASK',
device_secret: device.device_secret || '',
device_name: device.device_name || '光子美容仪',
firmware_version: device.firmware_version || '1.0.0'
}
)
}
/**
* Batch-create devices by IDs, return success/failure counts
* @param {string[]} deviceIds
* @returns {Promise<{created: number, failed: string[]}>}
*/
async function createBatch(deviceIds) {
let created = 0
const failed = []
for (const id of deviceIds) {
const deviceId = String(id || '').trim()
if (!deviceId) { failed.push(id); continue }
try {
await create({
device_id: deviceId,
product_id: 'HOX_LIGHT_MASK',
device_secret: '',
device_name: '光子美容仪',
firmware_version: '1.0.0'
})
created++
} catch (err) {
failed.push(deviceId)
}
}
return { created, failed }
}
/**
* Admin-unbind a device (set bind_status=2)
* @param {string} deviceId
* @returns {Promise<Array>} query result
*/
async function unbind(deviceId) {
return query(
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE device_id = :device_id AND bind_status = 1',
{ device_id: deviceId }
)
}
/**
* List user's bound devices with device details
* @param {number} userId
* @returns {Promise<Array>}
*/
async function listByUser(userId) {
return query(
'SELECT d.device_id, d.device_name, d.status, d.battery, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 ORDER BY b.bind_time DESC',
{ user_id: userId }
)
}
/**
* Get a single bound device for a user
* @param {number} userId
* @param {string} deviceId
* @returns {Promise<Object|null>}
*/
async function findBoundDevice(userId, deviceId) {
return one(
'SELECT d.device_id, d.device_name, d.status, d.battery, d.temperature, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 AND b.device_id = :device_id',
{ user_id: userId, device_id: deviceId }
)
}
module.exports = {
list,
count,
findById,
findByIdWithHistory,
create,
createBatch,
unbind,
listByUser,
findBoundDevice
}
+68
查看文件
@@ -0,0 +1,68 @@
const { query, one } = require('../lib/db')
/**
* List all firmware files ordered by creation date (newest first)
* @returns {Promise<Array>}
*/
async function list() {
return query(
'SELECT firmware_id, version, device_type, cos_key, size_bytes, sha256, status, created_at FROM firmware_files ORDER BY created_at DESC',
{}
)
}
/**
* Create a firmware record
* @param {Object} firmware
* @param {string} firmware.version
* @param {string} [firmware.device_type]
* @param {string} firmware.cos_key
* @param {number} [firmware.size_bytes]
* @param {string} [firmware.sha256]
* @param {number} [firmware.status] - 0=disabled, 1=enabled (default 1)
* @returns {Promise<Object>} query result with insertId
*/
async function create(firmware) {
return query(
'INSERT INTO firmware_files (version, device_type, cos_key, size_bytes, sha256, status) VALUES (:version, :device_type, :cos_key, :size_bytes, :sha256, :status)',
{
version: firmware.version,
device_type: firmware.device_type || '',
cos_key: firmware.cos_key,
size_bytes: Number(firmware.size_bytes || 0),
sha256: firmware.sha256 || '',
status: firmware.status === 0 ? 0 : 1
}
)
}
/**
* Update firmware status (enable/disable)
* @param {number} firmwareId
* @param {number} status - 0 or 1
* @returns {Promise<Array>} query result
*/
async function updateStatus(firmwareId, status) {
return query(
'UPDATE firmware_files SET status = :status WHERE firmware_id = :firmware_id',
{ status, firmware_id: firmwareId }
)
}
/**
* Find the latest enabled firmware
* @returns {Promise<Object|null>}
*/
async function findLatest() {
return one(
'SELECT * FROM firmware_files WHERE status = 1 ORDER BY created_at DESC LIMIT 1',
{}
)
}
module.exports = {
list,
create,
updateStatus,
findLatest
}
+13
查看文件
@@ -0,0 +1,13 @@
module.exports = {
adminDao: require('./admin.dao'),
deviceDao: require('./device.dao'),
bindingDao: require('./binding.dao'),
subscriptionDao: require('./subscription.dao'),
treatmentDao: require('./treatment.dao'),
userDao: require('./user.dao'),
logDao: require('./log.dao'),
commandDao: require('./command.dao'),
settingsDao: require('./settings.dao'),
firmwareDao: require('./firmware.dao'),
deviceEventDao: require('./device-event.dao')
}
+82
查看文件
@@ -0,0 +1,82 @@
const { query, limitClause } = require('../lib/db')
/**
* Write an operation log entry
* @param {Object} options
* @param {number|null} [options.user_id]
* @param {number|null} [options.admin_id]
* @param {string} options.action
* @param {string} [options.detail]
* @param {string} [options.ip]
* @returns {Promise<Array>} query result
*/
async function write(options) {
return query(
'INSERT INTO operation_logs (user_id, admin_id, action, detail, ip) VALUES (:user_id, :admin_id, :action, :detail, :ip)',
{
user_id: options.user_id || null,
admin_id: options.admin_id || null,
action: options.action,
detail: options.detail || '',
ip: options.ip || ''
}
)
}
/**
* Admin paginated list of operation logs with optional filters
* @param {Object} opts
* @param {string} [opts.type] - filter by action (LIKE match)
* @param {string} [opts.deviceId] - filter by detail containing device ID
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function list({ type, deviceId, pageSize, offset }) {
const conditions = []
const params = {}
if (type) {
conditions.push('action LIKE :type')
params.type = '%' + type + '%'
}
if (deviceId) {
conditions.push('detail LIKE :device_id')
params.device_id = '%' + deviceId + '%'
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
const total = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params)
const records = await query(
'SELECT * FROM operation_logs' + where + ' ORDER BY created_at DESC' + limitClause(pageSize, offset),
params
)
return { records, total: total[0].total }
}
/**
* Count operation logs with optional filters
* @param {Object} opts
* @param {string} [opts.type]
* @param {string} [opts.deviceId]
* @returns {Promise<number>}
*/
async function count({ type, deviceId }) {
const conditions = []
const params = {}
if (type) {
conditions.push('action LIKE :type')
params.type = '%' + type + '%'
}
if (deviceId) {
conditions.push('detail LIKE :device_id')
params.device_id = '%' + deviceId + '%'
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
const rows = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params)
return rows[0].total
}
module.exports = {
write,
list,
count
}
+40
查看文件
@@ -0,0 +1,40 @@
const { query } = require('../lib/db')
/**
* Get all system settings, parsing JSON values where possible
* @returns {Promise<Object>} key-value map of settings
*/
async function getAll() {
const rows = await query('SELECT setting_key, setting_value FROM system_settings', {})
const settings = {}
rows.forEach(row => {
if (typeof row.setting_value === 'string') {
try {
settings[row.setting_key] = JSON.parse(row.setting_value)
} catch (_) {
settings[row.setting_key] = row.setting_value
}
} else {
settings[row.setting_key] = row.setting_value
}
})
return settings
}
/**
* Upsert a system setting (REPLACE INTO)
* @param {string} key - setting_key
* @param {*} value - will be JSON-stringified
* @returns {Promise<Array>} query result
*/
async function update(key, value) {
return query(
'REPLACE INTO system_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value)',
{ setting_key: key, setting_value: JSON.stringify(value) }
)
}
module.exports = {
getAll,
update
}
+197
查看文件
@@ -0,0 +1,197 @@
const { query, one, transaction, limitClause } = require('../lib/db')
/**
* Find active subscription for a user with remaining days
* @param {number} userId
* @returns {Promise<Object|null>} subscription row with remaining_days, or null
*/
async function findActive(userId) {
return one(
'SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1',
{ user_id: userId }
)
}
/**
* Find active subscription summary (plan + remaining_days) for a user
* @param {number} userId
* @returns {Promise<Object|null>}
*/
async function findActiveSummary(userId) {
return one(
'SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1',
{ user_id: userId }
)
}
/**
* Check if a user has ever had a trial subscription
* @param {number} userId
* @returns {Promise<Object|null>} subscription row or null
*/
async function findTrial(userId) {
return one(
"SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = 'trial' LIMIT 1",
{ user_id: userId }
)
}
/**
* Check if a user has any active subscription
* @param {number} userId
* @returns {Promise<Object|null>}
*/
async function findAnyActive(userId) {
return one(
'SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1',
{ user_id: userId }
)
}
/**
* Create a trial subscription (7 days, amount=0)
* @param {number} userId
* @param {string} [orderId] - optional order ID
* @returns {Promise<Array>} query result
*/
async function createTrial(userId, orderId) {
return query(
"INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, 'trial', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))",
{ user_id: userId, order_id: orderId || 'TRIAL' + Date.now() }
)
}
/**
* Purchase / activate a subscription: expire old active subs, insert new one
* @param {number} userId
* @param {string} plan - 'monthly' | 'yearly' | 'trial'
* @param {number} amount
* @param {string} orderId
* @param {number} days
* @returns {Promise<void>}
*/
async function purchase(userId, plan, amount, orderId, days) {
return transaction(async conn => {
await conn.execute(
'UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1',
[userId]
)
await conn.execute(
'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))',
[userId, plan, amount, orderId, days]
)
})
}
/**
* Admin-create a subscription (expire old, insert new) without transaction
* Used by admin subscription creation endpoint
* @param {number} userId
* @param {string} plan
* @param {number} amount
* @param {string} orderId
* @param {number} days
* @returns {Promise<void>}
*/
async function adminCreate(userId, plan, amount, orderId, days) {
await query(
'UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1',
{ user_id: userId }
)
await query(
'INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))',
{ user_id: userId, plan, amount, order_id: orderId, days }
)
}
/**
* Cancel a subscription (set status=3)
* @param {number} subscriptionId
* @returns {Promise<Array>} query result (check affectedRows)
*/
async function cancel(subscriptionId) {
return query(
'UPDATE subscriptions SET status = 3 WHERE subscription_id = :subscription_id AND status = 1',
{ subscription_id: subscriptionId }
)
}
/**
* Admin paginated subscription list with user nickname and optional tab filter
* @param {Object} opts
* @param {string} [opts.tab] - 'all' | 'monthly' | 'yearly' | 'trial' | 'expired'
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function list({ tab, pageSize, offset }) {
let where = ''
const params = {}
if (tab && tab !== 'all') {
if (tab === 'expired') {
where = ' WHERE s.status = 2'
} else {
where = ' WHERE s.plan = :plan'
params.plan = tab
}
}
const total = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params)
const records = await query(
'SELECT s.*, u.nickname FROM subscriptions s LEFT JOIN users u ON u.user_id = s.user_id' +
where + ' ORDER BY s.created_at DESC' + limitClause(pageSize, offset),
params
)
return { records, total: total[0].total }
}
/**
* Count subscriptions with optional tab filter
* @param {Object} opts
* @param {string} [opts.tab]
* @returns {Promise<number>}
*/
async function count({ tab }) {
let where = ''
const params = {}
if (tab && tab !== 'all') {
if (tab === 'expired') {
where = ' WHERE s.status = 2'
} else {
where = ' WHERE s.plan = :plan'
params.plan = tab
}
}
const rows = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params)
return rows[0].total
}
/**
* Get subscription stats: plan counts + monthly revenue
* @returns {Promise<Object>} { monthly_count, yearly_count, trial_count, monthly_revenue }
*/
async function getStats() {
const rows = await query(
'SELECT ' +
"SUM(CASE WHEN plan = 'monthly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, " +
"SUM(CASE WHEN plan = 'yearly' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, " +
"SUM(CASE WHEN plan = 'trial' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, " +
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
'FROM subscriptions',
{}
)
return rows[0] || {}
}
module.exports = {
findActive,
findActiveSummary,
findTrial,
findAnyActive,
createTrial,
purchase,
adminCreate,
cancel,
list,
count,
getStats
}
+202
查看文件
@@ -0,0 +1,202 @@
const { query, one, limitClause } = require('../lib/db')
/**
* List treatment records for a user with pagination
* @param {number} userId
* @param {Object} opts
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function listByUser(userId, { pageSize, offset }) {
const total = await query(
'SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id',
{ user_id: userId }
)
const records = await query(
'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC' + limitClause(pageSize, offset),
{ user_id: userId }
)
return { records, total: total[0].total }
}
/**
* Count treatment records for a user
* @param {number} userId
* @returns {Promise<number>}
*/
async function countByUser(userId) {
const rows = await query(
'SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id',
{ user_id: userId }
)
return rows[0].total
}
/**
* Find a treatment record by session ID (optionally scoped to user)
* @param {string} sessionId
* @param {number} [userId] - if provided, restrict to this user
* @returns {Promise<Object|null>}
*/
async function findBySession(sessionId, userId) {
if (userId !== undefined) {
return one(
'SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id',
{ session_id: sessionId, user_id: userId }
)
}
return one(
'SELECT * FROM treatment_records WHERE session_id = :session_id',
{ session_id: sessionId }
)
}
/**
* Create or update a treatment record (upsert by session_id)
* @param {Object} record
* @param {string} record.session_id
* @param {string} record.device_id
* @param {number} record.user_id
* @param {string|null} record.start_time - MySQL datetime string
* @param {string|null} record.end_time
* @param {string} record.regions - comma-separated
* @param {number} record.total_duration_ms
* @param {number} record.mode
* @param {number} record.avg_pd
* @param {number|null} record.battery
* @param {number|null} record.temperature
* @param {number|null} record.wavelength
* @param {number|null} record.brightness
* @param {string} record.pd_json - JSON string
* @returns {Promise<Array>} query result
*/
async function create(record) {
return query(
`INSERT INTO treatment_records
(session_id, device_id, user_id, start_time, end_time, regions, total_duration_ms, mode, avg_pd, battery, temperature, wavelength, brightness, pd_json)
VALUES (:session_id, :device_id, :user_id, :start_time, :end_time, :regions, :total_duration_ms, :mode, :avg_pd, :battery, :temperature, :wavelength, :brightness, :pd_json)
ON DUPLICATE KEY UPDATE end_time = VALUES(end_time), total_duration_ms = VALUES(total_duration_ms), avg_pd = VALUES(avg_pd), battery = VALUES(battery), temperature = VALUES(temperature), pd_json = VALUES(pd_json)`,
record
)
}
/**
* Update device battery, temperature, and last_online_at
* @param {string} deviceId
* @param {number|null} battery
* @param {number|null} temperature
* @returns {Promise<Array>} query result
*/
async function updateDevice(deviceId, battery, temperature) {
return query(
'UPDATE devices SET battery = COALESCE(:battery, battery), temperature = COALESCE(:temperature, temperature), last_online_at = NOW() WHERE device_id = :device_id',
{ device_id: deviceId, battery, temperature }
)
}
/**
* Admin paginated list of treatment records with user nickname
* @param {Object} opts
* @param {string} [opts.keyword] - search by user nickname
* @param {string} [opts.dateFrom] - start date filter (inclusive)
* @param {string} [opts.dateTo] - end date filter (inclusive)
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function listAdmin({ keyword, dateFrom, dateTo, pageSize, offset }) {
const conditions = []
const params = {}
if (keyword) {
conditions.push('u.nickname LIKE :kw')
params.kw = '%' + keyword + '%'
}
if (dateFrom) {
conditions.push('r.created_at >= :date_from')
params.date_from = dateFrom
}
if (dateTo) {
conditions.push('r.created_at <= :date_to')
params.date_to = dateTo
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
const total = await query(
'SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where,
params
)
const records = await query(
'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' +
where + ' ORDER BY r.created_at DESC' + limitClause(pageSize, offset),
params
)
return { records, total: total[0].total }
}
/**
* Count admin treatment records with filters
* @param {Object} opts
* @param {string} [opts.keyword]
* @param {string} [opts.dateFrom]
* @param {string} [opts.dateTo]
* @returns {Promise<number>}
*/
async function countAdmin({ keyword, dateFrom, dateTo }) {
const conditions = []
const params = {}
if (keyword) {
conditions.push('u.nickname LIKE :kw')
params.kw = '%' + keyword + '%'
}
if (dateFrom) {
conditions.push('r.created_at >= :date_from')
params.date_from = dateFrom
}
if (dateTo) {
conditions.push('r.created_at <= :date_to')
params.date_to = dateTo
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
const rows = await query(
'SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where,
params
)
return rows[0].total
}
/**
* Get treatment stats for a user (count + total duration)
* @param {number} userId
* @returns {Promise<{treatment_count: number, total_duration: number}>}
*/
async function getStatsByUser(userId) {
const row = await one(
'SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id',
{ user_id: userId }
)
return row || { treatment_count: 0, total_duration: 0 }
}
/**
* Get recent treatments for a user (limit 5)
* @param {number} userId
* @returns {Promise<Array>}
*/
async function recentByUser(userId) {
return query(
'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5',
{ user_id: userId }
)
}
module.exports = {
listByUser,
countByUser,
findBySession,
create,
updateDevice,
listAdmin,
countAdmin,
getStatsByUser,
recentByUser
}
+178
查看文件
@@ -0,0 +1,178 @@
const { query, one, limitClause } = require('../lib/db')
/**
* Find user by WeChat openid
* @param {string} openid
* @returns {Promise<Object|null>}
*/
async function findByOpenid(openid) {
return one(
'SELECT * FROM users WHERE openid = :openid',
{ openid }
)
}
/**
* Create a new user from WeChat login
* @param {string} openid
* @returns {Promise<Object>} query result with insertId
*/
async function create(openid) {
return query(
'INSERT INTO users (openid, nickname, avatar, status) VALUES (:openid, :nickname, :avatar, 1)',
{ openid, nickname: '', avatar: '' }
)
}
/**
* Find user by user_id
* @param {number} userId
* @returns {Promise<Object|null>}
*/
async function findById(userId) {
return one(
'SELECT * FROM users WHERE user_id = :user_id',
{ user_id: userId }
)
}
/**
* Find active user by user_id (status=1)
* @param {number} userId
* @returns {Promise<Object|null>}
*/
async function findActiveById(userId) {
return one(
'SELECT * FROM users WHERE user_id = :user_id AND status = 1',
{ user_id: userId }
)
}
/**
* Update user profile fields (nickname, avatar, gender)
* @param {number} userId
* @param {Object} fields
* @param {string|null} [fields.nickname]
* @param {string|null} [fields.avatar]
* @param {number|null} [fields.gender]
* @returns {Promise<Array>} query result
*/
async function updateProfile(userId, fields) {
return query(
'UPDATE users SET nickname = COALESCE(:nickname, nickname), avatar = COALESCE(:avatar, avatar), gender = COALESCE(:gender, gender) WHERE user_id = :user_id',
{
user_id: userId,
nickname: fields.nickname || null,
avatar: fields.avatar || null,
gender: fields.gender === undefined ? null : fields.gender
}
)
}
/**
* Update user phone number
* @param {number} userId
* @param {string} phone
* @returns {Promise<Array>} query result
*/
async function updatePhone(userId, phone) {
return query(
'UPDATE users SET phone = :phone WHERE user_id = :user_id',
{ user_id: userId, phone }
)
}
/**
* Admin paginated user list with device/treatment/subscription subquery stats
* @param {Object} opts
* @param {string} [opts.keyword] - search nickname, phone, or exact user_id
* @param {number} opts.pageSize
* @param {number} opts.offset
* @returns {Promise<{records: Array, total: number}>}
*/
async function listAdmin({ keyword, pageSize, offset }) {
let where = ''
const params = {}
if (keyword) {
where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword'
params.kw = '%' + keyword + '%'
params.keyword = keyword
}
const total = await query('SELECT COUNT(*) AS total FROM users u' + where, params)
const records = await query(
'SELECT u.*,' +
' (SELECT COUNT(*) FROM bindings WHERE user_id = u.user_id AND bind_status = 1) AS device_count,' +
' (SELECT COUNT(*) FROM treatment_records WHERE user_id = u.user_id) AS treatment_count,' +
' COALESCE((SELECT status FROM subscriptions WHERE user_id = u.user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1), 0) AS subscription_status' +
' FROM users u' + where + ' ORDER BY u.created_at DESC' + limitClause(pageSize, offset),
params
)
return { records, total: total[0].total }
}
/**
* Count admin users with optional keyword
* @param {Object} opts
* @param {string} [opts.keyword]
* @returns {Promise<number>}
*/
async function countAdmin({ keyword }) {
let where = ''
const params = {}
if (keyword) {
where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword'
params.kw = '%' + keyword + '%'
params.keyword = keyword
}
const rows = await query('SELECT COUNT(*) AS total FROM users u' + where, params)
return rows[0].total
}
/**
* Admin detail view: user + bound devices, recent treatments, subscription, stats
* @param {number} userId
* @returns {Promise<Object|null>} enriched user object or null
*/
async function findByIdAdmin(userId) {
const user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: userId })
if (!user) return null
const devices = await query(
'SELECT d.device_id, d.device_name FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1',
{ user_id: userId }
)
const treatments = await query(
'SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5',
{ user_id: userId }
)
const subscription = await one(
'SELECT plan, status, start_time, expire_time FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1',
{ user_id: userId }
)
const stats = await one(
'SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id',
{ user_id: userId }
)
return Object.assign({}, user, {
devices,
recent_treatments: treatments,
subscription_status: subscription ? subscription.status : 0,
subscription_type: subscription ? subscription.plan : null,
subscription_expire: subscription ? subscription.expire_time : null,
treatment_count: stats ? stats.treatment_count : 0,
total_duration: stats ? stats.total_duration : 0
})
}
module.exports = {
findByOpenid,
create,
findById,
findActiveById,
updateProfile,
updatePhone,
listAdmin,
countAdmin,
findByIdAdmin
}
+5 -2
查看文件
@@ -1,7 +1,10 @@
const { handle } = require('./app')
const serverless = require('./lib/serverless')
const app = require('./app')
const handler = serverless(app)
exports.main_handler = async (event, context) => {
return handle(event, context)
return handler(event, context)
}
exports.main = exports.main_handler
+1 -26
查看文件
@@ -2,7 +2,6 @@ const crypto = require('crypto')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcryptjs')
const config = require('../config')
const { one } = require('./db')
function hashPasswordLegacy(password, salt) {
return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex')
@@ -34,28 +33,4 @@ function readBearer(headers) {
return match ? match[1] : ''
}
async function requireUser(ctx) {
const token = readBearer(ctx.headers)
if (!token) return null
try {
const payload = jwt.verify(token, config.jwt.secret)
if (payload.type !== 'user') return null
return await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
} catch (err) {
return null
}
}
async function requireAdmin(ctx) {
const token = readBearer(ctx.headers)
if (!token) return null
try {
const payload = jwt.verify(token, config.jwt.adminSecret)
if (payload.type !== 'admin') return null
return await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: payload.admin_id })
} catch (err) {
return null
}
}
module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer, requireUser, requireAdmin }
module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer }
+2 -16
查看文件
@@ -1,16 +1,2 @@
const { query } = require('./db')
async function writeLog(options) {
await query(
'INSERT INTO operation_logs (user_id, admin_id, action, detail, ip) VALUES (:user_id, :admin_id, :action, :detail, :ip)',
{
user_id: options.user_id || null,
admin_id: options.admin_id || null,
action: options.action,
detail: options.detail || '',
ip: options.ip || ''
}
)
}
module.exports = { writeLog }
const logDao = require('../dao/log.dao')
module.exports = { writeLog: logDao.write }
-45
查看文件
@@ -1,45 +0,0 @@
function normalizeHeaders(headers) {
const result = {}
Object.keys(headers || {}).forEach(key => {
result[key] = headers[key]
result[key.toLowerCase()] = headers[key]
})
return result
}
function parseBody(event) {
if (!event.body) return {}
if (typeof event.body === 'object') return event.body
const raw = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body
if (!raw) return {}
try { return JSON.parse(raw) } catch (err) { return {} }
}
function parseQuery(event) {
if (event.queryStringParameters) return event.queryStringParameters || {}
if (event.query) return event.query || {}
return {}
}
function getPath(event) {
return event.path || event.Path || event.requestContext && event.requestContext.path || '/'
}
function getMethod(event) {
return String(event.httpMethod || event.method || event.requestContext && event.requestContext.httpMethod || 'GET').toUpperCase()
}
function createContext(event) {
return {
event,
method: getMethod(event),
path: getPath(event),
headers: normalizeHeaders(event.headers),
query: parseQuery(event),
body: parseBody(event),
params: {},
ip: event.requestContext && event.requestContext.sourceIp || (event.headers && (event.headers['x-forwarded-for'] || event.headers['X-Forwarded-For'] || '').split(',')[0].trim()) || ''
}
}
module.exports = { createContext }
+1 -15
查看文件
@@ -6,18 +6,4 @@ function fail(code, message, data) {
return { code, message, data: data || {} }
}
function http(statusCode, body, headers) {
return {
isBase64Encoded: false,
statusCode,
headers: Object.assign({
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS'
}, headers || {}),
body: JSON.stringify(body)
}
}
module.exports = { ok, fail, http }
module.exports = { ok, fail }
-33
查看文件
@@ -1,33 +0,0 @@
class Router {
constructor() {
this.routes = []
}
add(method, pattern, handler) {
const keys = []
const regex = new RegExp('^' + pattern.replace(/\/:(\w+)/g, function (_, key) {
keys.push(key)
return '/([^/]+)'
}) + '$')
this.routes.push({ method, regex, keys, handler })
}
get(pattern, handler) { this.add('GET', pattern, handler) }
post(pattern, handler) { this.add('POST', pattern, handler) }
put(pattern, handler) { this.add('PUT', pattern, handler) }
delete(pattern, handler) { this.add('DELETE', pattern, handler) }
match(method, path) {
for (const route of this.routes) {
if (route.method !== method) continue
const match = path.match(route.regex)
if (!match) continue
const params = {}
route.keys.forEach((key, index) => { params[key] = decodeURIComponent(match[index + 1]) })
return { handler: route.handler, params }
}
return null
}
}
module.exports = Router
+63
查看文件
@@ -0,0 +1,63 @@
const http = require('http')
module.exports = function serverless(app) {
return async function handler(event) {
const method = String(event.httpMethod || event.method || 'GET').toUpperCase()
const path = event.path || '/'
const headers = event.headers || {}
const qs = event.queryStringParameters || {}
const qsStr = Object.keys(qs).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(qs[k])).join('&')
const url = path + (qsStr ? '?' + qsStr : '')
let rawBody = event.body || ''
if (event.isBase64Encoded && rawBody) rawBody = Buffer.from(rawBody, 'base64').toString('utf8')
return new Promise((resolve) => {
const req = new http.IncomingMessage()
req.method = method
req.url = url
req.headers = {}
Object.keys(headers).forEach(k => { req.headers[k.toLowerCase()] = headers[k] })
if (event.requestContext && event.requestContext.sourceIp) {
req.headers['x-forwarded-for'] = req.headers['x-forwarded-for'] || event.requestContext.sourceIp
}
const res = new http.ServerResponse(req)
let body = ''
const resHeaders = {}
res.writeHead = function (statusCode, reasonOrHeaders, maybeHeaders) {
res.statusCode = statusCode
const h = maybeHeaders || (typeof reasonOrHeaders === 'object' ? reasonOrHeaders : {})
Object.assign(resHeaders, h)
}
const originalSetHeader = res.setHeader.bind(res)
res.setHeader = function (name, value) {
resHeaders[name.toLowerCase()] = value
originalSetHeader(name, value)
}
res.end = function (chunk) {
if (chunk) body += chunk
resolve({
isBase64Encoded: false,
statusCode: res.statusCode || 200,
headers: Object.assign({
'content-type': 'application/json; charset=utf-8'
}, resHeaders),
body
})
}
res.write = function (chunk) { body += chunk }
if (rawBody) {
req.push(rawBody)
}
req.push(null)
app(req, res)
})
}
}
+46
查看文件
@@ -0,0 +1,46 @@
const jwt = require('jsonwebtoken')
const config = require('../config')
const { one } = require('../lib/db')
function readBearer(headers) {
const auth = headers.authorization || ''
const match = auth.match(/^Bearer\s+(.+)$/i)
return match ? match[1] : ''
}
function authMiddleware(req, res, next) {
req.ip = req.headers['x-forwarded-for']
? req.headers['x-forwarded-for'].split(',')[0].trim()
: req.ip || ''
next()
}
async function requireUser(req, res, next) {
const token = readBearer(req.headers)
if (!token) return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
try {
const payload = jwt.verify(token, config.jwt.secret)
if (payload.type !== 'user') return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
req.user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
if (!req.user) return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
next()
} catch (err) {
return res.status(401).json({ code: 1001, message: 'invalid_token', data: {} })
}
}
async function requireAdmin(req, res, next) {
const token = readBearer(req.headers)
if (!token) return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
try {
const payload = jwt.verify(token, config.jwt.adminSecret)
if (payload.type !== 'admin') return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
req.admin = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: payload.admin_id })
if (!req.admin) return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
next()
} catch (err) {
return res.status(401).json({ code: 1002, message: '未授权,请重新登录', data: {} })
}
}
module.exports = { authMiddleware, requireUser, requireAdmin, readBearer }
+207 -336
查看文件
@@ -1,364 +1,235 @@
const { one, query, limitClause } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin, requireAdmin } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin } = require('../lib/auth')
const { requireAdmin } = require('../middleware/auth')
const adminDao = require('../dao/admin.dao')
const deviceDao = require('../dao/device.dao')
const bindingDao = require('../dao/binding.dao')
const commandDao = require('../dao/command.dao')
const userDao = require('../dao/user.dao')
const subscriptionDao = require('../dao/subscription.dao')
const treatmentDao = require('../dao/treatment.dao')
const logDao = require('../dao/log.dao')
const settingsDao = require('../dao/settings.dao')
function pageParams(ctx) {
const page = Math.max(1, parseInt(ctx.query.page, 10) || 1)
const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size, 10) || 20), 100)
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
function pageParams(query) {
const page = Math.max(1, parseInt(query.page, 10) || 1)
const pageSize = Math.min(Math.max(1, parseInt(query.page_size, 10) || 20), 100)
return { page, pageSize, offset: (page - 1) * pageSize }
}
function register(router) {
router.post('/api/v1/admin/login', async ctx => {
const username = ctx.body.username || ''
const password = ctx.body.password || ''
const admin = await one('SELECT * FROM admin_accounts WHERE username = :username AND status = 1', { username })
if (!admin) return fail(1001, '用户名或密码错误')
let matched = verifyPassword(password, admin.password_hash)
if (!matched) {
// Try legacy SHA-256 verification for migration
if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) {
// Auto-migrate to bcrypt
const newHash = hashPassword(password)
await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id })
matched = true
}
}
if (!matched) return fail(1001, '用户名或密码错误')
const token = signAdmin(admin)
await writeLog({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: ctx.ip })
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
})
// --- Auth ---
router.post('/api/v1/admin/password', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const oldPassword = ctx.body.old_password || ''
const newPassword = ctx.body.new_password || ''
if (newPassword.length < 6) return fail(2001, 'password too short')
const current = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: admin.admin_id })
if (!current) return fail(1002, '未授权,请重新登录')
let matched = verifyPassword(oldPassword, current.password_hash)
if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) {
router.post('/login', wrap(async (req, res) => {
const username = req.body.username || ''
const password = req.body.password || ''
const admin = await adminDao.findByUsername(username)
if (!admin) return res.json(fail(1001, '用户名或密码错误'))
let matched = verifyPassword(password, admin.password_hash)
if (!matched) {
if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) {
const newHash = hashPassword(password)
await adminDao.updatePassword(admin.admin_id, newHash)
matched = true
}
if (!matched) return fail(1001, '原密码错误')
const newHash = hashPassword(newPassword)
await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id })
await writeLog({ admin_id: admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: ctx.ip })
return ok({ message: 'success' })
})
}
if (!matched) return res.json(fail(1001, '用户名或密码错误'))
const token = signAdmin(admin)
await logDao.write({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: req.ip })
res.json(ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role }))
}))
router.get('/api/v1/admin/dashboard', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const rows = await Promise.all([
query('SELECT COUNT(*) AS total FROM devices', {}),
query('SELECT COUNT(*) AS total FROM users', {}),
query('SELECT COUNT(*) AS total FROM treatment_records', {}),
query('SELECT COUNT(*) AS total FROM subscriptions WHERE status = 1 AND expire_time > NOW()', {})
])
const subStats = await query('SELECT ' +
'SUM(CASE WHEN plan = \'monthly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, ' +
'SUM(CASE WHEN plan = \'yearly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, ' +
'SUM(CASE WHEN plan = \'trial\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, ' +
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
'FROM subscriptions', {})
return ok({
device_count: rows[0][0].total,
user_count: rows[1][0].total,
treatment_count: rows[2][0].total,
subscription_count: rows[3][0].total,
sub_stats: subStats[0] || {}
})
})
router.post('/password', requireAdmin, wrap(async (req, res) => {
const oldPassword = req.body.old_password || ''
const newPassword = req.body.new_password || ''
if (newPassword.length < 6) return res.json(fail(2001, 'password too short'))
const current = await adminDao.findById(req.admin.admin_id)
if (!current) return res.json(fail(1002, '未授权,请重新登录'))
let matched = verifyPassword(oldPassword, current.password_hash)
if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) {
matched = true
}
if (!matched) return res.json(fail(1001, '原密码错误'))
const newHash = hashPassword(newPassword)
await adminDao.updatePassword(req.admin.admin_id, newHash)
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: req.ip })
res.json(ok({ message: 'success' }))
}))
router.get('/api/v1/admin/devices', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const keyword = (ctx.query.keyword || '').trim()
let where = ''
const params = {}
if (keyword) {
where = ' WHERE d.device_id LIKE :kw OR d.device_name LIKE :kw'
params.kw = '%' + keyword + '%'
}
const total = await query('SELECT COUNT(*) AS total FROM devices d' + where, params)
const records = await query('SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1' + where + ' ORDER BY d.created_at DESC' + limitClause(p.pageSize, p.offset), params)
return ok({ records, total: total[0].total })
})
// --- Dashboard ---
router.post('/api/v1/admin/devices', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const deviceId = String(ctx.body.device_id || '').trim()
if (!deviceId) return fail(2001, 'device_id required')
await query(
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
{
device_id: deviceId,
product_id: ctx.body.product_id || 'HOX_LIGHT_MASK',
device_secret: ctx.body.device_secret || '',
device_name: ctx.body.device_name || '光子美容仪',
firmware_version: ctx.body.firmware_version || '1.0.0'
}
)
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_create', detail: '预生成产品码: ' + deviceId, ip: ctx.ip })
return ok({ device_id: deviceId })
})
router.get('/dashboard', requireAdmin, wrap(async (req, res) => {
const counts = await adminDao.getDashboardCounts()
const subStats = await adminDao.getSubscriptionStats()
res.json(ok({
device_count: counts.device_count,
user_count: counts.user_count,
treatment_count: counts.treatment_count,
subscription_count: counts.subscription_count,
sub_stats: subStats
}))
}))
router.post('/api/v1/admin/devices/batch', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const deviceIds = ctx.body.device_ids
if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) return fail(2001, 'device_ids must be an array with 1-500 items')
let successCount = 0
const failedIds = []
for (const id of deviceIds) {
const deviceId = String(id || '').trim()
if (!deviceId) { failedIds.push(id); continue }
try {
await query(
'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
{
device_id: deviceId,
product_id: 'HOX_LIGHT_MASK',
device_secret: '',
device_name: '光子美容仪',
firmware_version: '1.0.0'
}
)
successCount++
} catch (err) {
failedIds.push(deviceId)
}
}
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + successCount + '/' + deviceIds.length, ip: ctx.ip })
return ok({ created: successCount, failed: failedIds })
})
// --- Devices ---
router.get('/api/v1/admin/devices/:device_id', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const device = await one('SELECT d.*, b.user_id AS bound_user, b.bind_time AS activated_at FROM devices d LEFT JOIN bindings b ON b.device_id = d.device_id AND b.bind_status = 1 WHERE d.device_id = :device_id', { device_id: ctx.params.device_id })
if (!device) return fail(1005, 'DEVICE_NOT_FOUND')
const bindingHistory = await query('SELECT b.*, u.nickname FROM bindings b LEFT JOIN users u ON u.user_id = b.user_id WHERE b.device_id = :device_id ORDER BY b.bind_time DESC', { device_id: ctx.params.device_id })
const recentTreatments = await query('SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id WHERE r.device_id = :device_id ORDER BY r.created_at DESC LIMIT 5', { device_id: ctx.params.device_id })
return ok(Object.assign({}, device, { binding_history: bindingHistory, recent_treatments: recentTreatments }))
router.get('/devices', requireAdmin, wrap(async (req, res) => {
const { page, pageSize, offset } = pageParams(req.query)
const { records, total } = await deviceDao.list({
keyword: req.query.keyword,
pageSize,
offset
})
res.json(ok({ records, total }))
}))
router.post('/api/v1/admin/devices/:device_id/unbind', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
await query('UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE device_id = :device_id AND bind_status = 1', { device_id: ctx.params.device_id })
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_unbind', detail: '后台解绑设备: ' + ctx.params.device_id, ip: ctx.ip })
return ok({ message: 'success' })
router.post('/devices', requireAdmin, wrap(async (req, res) => {
const deviceId = String(req.body.device_id || '').trim()
if (!deviceId) return res.json(fail(2001, 'device_id required'))
await deviceDao.create({
device_id: deviceId,
product_id: req.body.product_id || 'HOX_LIGHT_MASK',
device_secret: req.body.device_secret || '',
device_name: req.body.device_name || '光子美容仪',
firmware_version: req.body.firmware_version || '1.0.0'
})
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_create', detail: '预生成产品码: ' + deviceId, ip: req.ip })
res.json(ok({ device_id: deviceId }))
}))
router.post('/api/v1/admin/devices/:device_id/command', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const opcode = parseInt(ctx.body.opcode, 10)
if (!opcode) return fail(2001, 'opcode required')
await query(
'INSERT INTO device_commands (device_id, admin_id, opcode, payload_json, status) VALUES (:device_id, :admin_id, :opcode, :payload_json, 1)',
{ device_id: ctx.params.device_id, admin_id: admin.admin_id, opcode, payload_json: JSON.stringify(ctx.body) }
)
await writeLog({ admin_id: admin.admin_id, action: 'admin_device_command', detail: '记录远程指令: ' + ctx.params.device_id, ip: ctx.ip })
return ok({ message: 'queued', command: ctx.body })
router.post('/devices/batch', requireAdmin, wrap(async (req, res) => {
const deviceIds = req.body.device_ids
if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) {
return res.json(fail(2001, 'device_ids must be an array with 1-500 items'))
}
const { created, failed } = await deviceDao.createBatch(deviceIds)
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + created + '/' + deviceIds.length, ip: req.ip })
res.json(ok({ created, failed }))
}))
router.get('/devices/:device_id', requireAdmin, wrap(async (req, res) => {
const result = await deviceDao.findByIdWithHistory(req.params.device_id)
if (!result) return res.json(fail(1005, 'DEVICE_NOT_FOUND'))
res.json(ok(result))
}))
router.post('/devices/:device_id/unbind', requireAdmin, wrap(async (req, res) => {
await deviceDao.unbind(req.params.device_id)
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_unbind', detail: '后台解绑设备: ' + req.params.device_id, ip: req.ip })
res.json(ok({ message: 'success' }))
}))
router.post('/devices/:device_id/command', requireAdmin, wrap(async (req, res) => {
const opcode = parseInt(req.body.opcode, 10)
if (!opcode) return res.json(fail(2001, 'opcode required'))
await commandDao.create(req.params.device_id, req.admin.admin_id, opcode, req.body)
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_device_command', detail: '记录远程指令: ' + req.params.device_id, ip: req.ip })
res.json(ok({ message: 'queued', command: req.body }))
}))
router.get('/devices/:device_id/commands', requireAdmin, wrap(async (req, res) => {
const { page, pageSize, offset } = pageParams(req.query)
const { records, total } = await commandDao.listByDevice(req.params.device_id, { pageSize, offset })
res.json(ok({ records, total }))
}))
// --- Users ---
router.get('/users', requireAdmin, wrap(async (req, res) => {
const { page, pageSize, offset } = pageParams(req.query)
const { records, total } = await userDao.listAdmin({
keyword: req.query.keyword,
pageSize,
offset
})
res.json(ok({ records, total }))
}))
router.get('/api/v1/admin/devices/:device_id/commands', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const total = await query('SELECT COUNT(*) AS total FROM device_commands WHERE device_id = :device_id', { device_id: ctx.params.device_id })
const records = await query(
'SELECT command_id, device_id, admin_id, opcode, payload_json, status, created_at, pulled_at, finished_at, result_json FROM device_commands WHERE device_id = :device_id ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset),
{ device_id: ctx.params.device_id }
)
return ok({ records, total: total[0].total })
router.get('/users/:user_id', requireAdmin, wrap(async (req, res) => {
const result = await userDao.findByIdAdmin(req.params.user_id)
if (!result) return res.json(fail(1004, 'USER_NOT_FOUND'))
res.json(ok(result))
}))
// --- Subscriptions ---
router.get('/subscriptions', requireAdmin, wrap(async (req, res) => {
const { page, pageSize, offset } = pageParams(req.query)
const { records, total } = await subscriptionDao.list({
tab: req.query.tab,
pageSize,
offset
})
const stats = await subscriptionDao.getStats()
res.json(ok({ records, total, stats }))
}))
router.get('/api/v1/admin/users', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const keyword = (ctx.query.keyword || '').trim()
let where = ''
const params = {}
if (keyword) {
where = ' WHERE u.nickname LIKE :kw OR u.phone LIKE :kw OR u.user_id = :keyword'
params.kw = '%' + keyword + '%'
params.keyword = keyword
}
const total = await query('SELECT COUNT(*) AS total FROM users u' + where, params)
const records = await query(
'SELECT u.*,' +
' (SELECT COUNT(*) FROM bindings WHERE user_id = u.user_id AND bind_status = 1) AS device_count,' +
' (SELECT COUNT(*) FROM treatment_records WHERE user_id = u.user_id) AS treatment_count,' +
' COALESCE((SELECT status FROM subscriptions WHERE user_id = u.user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1), 0) AS subscription_status' +
' FROM users u' + where + ' ORDER BY u.created_at DESC' + limitClause(p.pageSize, p.offset),
params
)
return ok({ records, total: total[0].total })
router.post('/subscriptions', requireAdmin, wrap(async (req, res) => {
const userId = req.body.user_id
if (!userId) return res.json(fail(2001, 'user_id required'))
const targetUser = await userDao.findById(userId)
if (!targetUser) return res.json(fail(1004, 'user_not_found'))
await subscriptionDao.adminCreate(
userId,
req.body.plan || 'monthly',
req.body.amount || 0,
req.body.order_id || 'ADMIN' + Date.now(),
req.body.days || 30
)
res.json(ok({ message: 'success' }))
}))
router.post('/subscriptions/cancel', requireAdmin, wrap(async (req, res) => {
const subscriptionId = req.body.subscription_id
if (!subscriptionId) return res.json(fail(2001, 'subscription_id required'))
const result = await subscriptionDao.cancel(subscriptionId)
if (result.affectedRows === 0) return res.json(fail(2001, '未找到有效订阅'))
await logDao.write({ admin_id: req.admin.admin_id, action: 'subscription_cancel', detail: '取消订阅 #' + subscriptionId, ip: req.ip })
res.json(ok({ message: 'success' }))
}))
// --- Treatment Records ---
router.get('/records', requireAdmin, wrap(async (req, res) => {
const { page, pageSize, offset } = pageParams(req.query)
const { records, total } = await treatmentDao.listAdmin({
keyword: req.query.keyword,
dateFrom: req.query.date_from,
dateTo: req.query.date_to,
pageSize,
offset
})
res.json(ok({ records, total }))
}))
router.get('/api/v1/admin/users/:user_id', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: ctx.params.user_id })
if (!user) return fail(1004, 'USER_NOT_FOUND')
const devices = await query('SELECT d.device_id, d.device_name FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1', { user_id: user.user_id })
const treatments = await query('SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC LIMIT 5', { user_id: user.user_id })
const subscription = await one('SELECT plan, status, start_time, expire_time FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
const stats = await one('SELECT COUNT(*) AS treatment_count, COALESCE(SUM(total_duration_ms), 0) AS total_duration FROM treatment_records WHERE user_id = :user_id', { user_id: user.user_id })
return ok(Object.assign({}, user, {
devices,
recent_treatments: treatments,
subscription_status: subscription ? subscription.status : 0,
subscription_type: subscription ? subscription.plan : null,
subscription_expire: subscription ? subscription.expire_time : null,
treatment_count: stats ? stats.treatment_count : 0,
total_duration: stats ? stats.total_duration : 0
}))
// --- Logs ---
router.get('/logs', requireAdmin, wrap(async (req, res) => {
const { page, pageSize, offset } = pageParams(req.query)
const { records, total } = await logDao.list({
type: req.query.type,
deviceId: req.query.device_id,
pageSize,
offset
})
res.json(ok({ records, total }))
}))
router.get('/api/v1/admin/subscriptions', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const tab = (ctx.query.tab || '').trim()
let where = ''
const params = {}
if (tab && tab !== 'all') {
if (tab === 'expired') {
where = ' WHERE s.status = 2'
} else {
where = ' WHERE s.plan = :plan'
params.plan = tab
}
}
const total = await query('SELECT COUNT(*) AS total FROM subscriptions s' + where, params)
const records = await query(
'SELECT s.*, u.nickname FROM subscriptions s LEFT JOIN users u ON u.user_id = s.user_id' + where + ' ORDER BY s.created_at DESC' + limitClause(p.pageSize, p.offset),
params
)
const statsRow = await query('SELECT ' +
'SUM(CASE WHEN plan = \'monthly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS monthly_count, ' +
'SUM(CASE WHEN plan = \'yearly\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS yearly_count, ' +
'SUM(CASE WHEN plan = \'trial\' AND status = 1 AND expire_time > NOW() THEN 1 ELSE 0 END) AS trial_count, ' +
'COALESCE(SUM(CASE WHEN MONTH(start_time) = MONTH(NOW()) AND YEAR(start_time) = YEAR(NOW()) THEN amount ELSE 0 END), 0) AS monthly_revenue ' +
'FROM subscriptions', {})
return ok({ records, total: total[0].total, stats: statsRow[0] || {} })
})
// --- Settings ---
router.post('/api/v1/admin/subscriptions', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const targetUser = await one('SELECT user_id FROM users WHERE user_id = :user_id', { user_id: ctx.body.user_id })
if (!targetUser) return fail(1004, 'user_not_found')
await query('UPDATE subscriptions SET status = 2 WHERE user_id = :user_id AND status = 1', { user_id: ctx.body.user_id })
await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, :plan, 1, :amount, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))', {
user_id: ctx.body.user_id,
plan: ctx.body.plan || 'monthly',
amount: ctx.body.amount || 0,
order_id: ctx.body.order_id || 'ADMIN' + Date.now(),
days: ctx.body.days || 30
})
return ok({ message: 'success' })
})
router.get('/settings', requireAdmin, wrap(async (req, res) => {
const settings = await settingsDao.getAll()
res.json(ok(settings))
}))
router.post('/api/v1/admin/subscriptions/cancel', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const subscriptionId = ctx.body.subscription_id
if (!subscriptionId) return fail(2001, 'subscription_id required')
const result = await query('UPDATE subscriptions SET status = 3 WHERE subscription_id = :subscription_id AND status = 1', { subscription_id: subscriptionId })
if (result.affectedRows === 0) return fail(2001, '未找到有效订阅')
await writeLog({ admin_id: admin.admin_id, action: 'subscription_cancel', detail: '取消订阅 #' + subscriptionId, ip: ctx.ip })
return ok({ message: 'success' })
})
router.post('/settings', requireAdmin, wrap(async (req, res) => {
const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode']
for (const key of Object.keys(req.body || {})) {
if (!ALLOWED_KEYS.includes(key)) continue
await settingsDao.update(key, req.body[key])
}
res.json(ok({ message: 'success' }))
}))
router.get('/api/v1/admin/records', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const keyword = (ctx.query.keyword || '').trim()
const dateFrom = (ctx.query.date_from || '').trim()
const dateTo = (ctx.query.date_to || '').trim()
const conditions = []
const params = {}
if (keyword) {
conditions.push('u.nickname LIKE :kw')
params.kw = '%' + keyword + '%'
}
if (dateFrom) {
conditions.push('r.created_at >= :date_from')
params.date_from = dateFrom
}
if (dateTo) {
conditions.push('r.created_at <= :date_to')
params.date_to = dateTo
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
const total = await query('SELECT COUNT(*) AS total FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where, params)
const records = await query(
'SELECT r.*, u.nickname FROM treatment_records r LEFT JOIN users u ON u.user_id = r.user_id' + where + ' ORDER BY r.created_at DESC' + limitClause(p.pageSize, p.offset),
params
)
return ok({ records, total: total[0].total })
})
router.get('/api/v1/admin/logs', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const p = pageParams(ctx)
const type = (ctx.query.type || '').trim()
const deviceId = (ctx.query.device_id || '').trim()
const conditions = []
const params = {}
if (type) {
conditions.push('action LIKE :type')
params.type = '%' + type + '%'
}
if (deviceId) {
conditions.push('detail LIKE :device_id')
params.device_id = '%' + deviceId + '%'
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''
const total = await query('SELECT COUNT(*) AS total FROM operation_logs' + where, params)
const records = await query('SELECT * FROM operation_logs' + where + ' ORDER BY created_at DESC' + limitClause(p.pageSize, p.offset), params)
return ok({ records, total: total[0].total })
})
router.get('/api/v1/admin/settings', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const rows = await query('SELECT setting_key, setting_value FROM system_settings', {})
const settings = {}
rows.forEach(row => {
if (typeof row.setting_value === 'string') {
try { settings[row.setting_key] = JSON.parse(row.setting_value) } catch (_) { settings[row.setting_key] = row.setting_value }
} else {
settings[row.setting_key] = row.setting_value
}
})
return ok(settings)
})
router.post('/api/v1/admin/settings', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const ALLOWED_KEYS = ['system_name', 'admin_email', 'timezone', 'monthly_price', 'yearly_price', 'trial_days', 'enable_register', 'enable_binding', 'enable_free_mode', 'enable_smart_mode', 'maintenance_mode']
for (const key of Object.keys(ctx.body || {})) {
if (!ALLOWED_KEYS.includes(key)) continue
await query('REPLACE INTO system_settings (setting_key, setting_value) VALUES (:setting_key, :setting_value)', { setting_key: key, setting_value: JSON.stringify(ctx.body[key]) })
}
return ok({ message: 'success' })
})
}
module.exports = register
module.exports = router
+59 -62
查看文件
@@ -1,78 +1,75 @@
const jwt = require('jsonwebtoken')
const { one, query } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { signUser, readBearer } = require('../lib/auth')
const { code2Session } = require('../lib/wechat')
const { writeLog } = require('../lib/log')
const config = require('../config')
const userDao = require('../dao/user.dao')
const logDao = require('../dao/log.dao')
function register(router) {
router.post('/api/v1/auth/login', async ctx => {
const session = await code2Session(ctx.body.code || '')
let user = await one('SELECT * FROM users WHERE openid = :openid', { openid: session.openid })
if (!user) {
const result = await query(
'INSERT INTO users (openid, nickname, avatar, status) VALUES (:openid, :nickname, :avatar, 1)',
{ openid: session.openid, nickname: '', avatar: '' }
)
user = await one('SELECT * FROM users WHERE user_id = :user_id', { user_id: result.insertId })
await writeLog({ user_id: user.user_id, action: 'user_register', detail: '新用户注册', ip: ctx.ip })
}
const token = signUser(user)
await writeLog({ user_id: user.user_id, action: 'user_login', detail: '用户登录', ip: ctx.ip })
return ok({
token,
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
router.post('/auth/login', wrap(async (req, res) => {
const session = await code2Session(req.body.code || '')
let user = await userDao.findByOpenid(session.openid)
if (!user) {
const result = await userDao.create(session.openid)
user = await userDao.findById(result.insertId)
await logDao.write({ user_id: user.user_id, action: 'user_register', detail: '新用户注册', ip: req.ip })
}
const token = signUser(user)
await logDao.write({ user_id: user.user_id, action: 'user_login', detail: '用户登录', ip: req.ip })
res.json(ok({
token,
user_id: String(user.user_id),
user_info: {
user_id: String(user.user_id),
user_info: {
user_id: String(user.user_id),
nickname: user.nickname || '用户' + String(user.user_id),
avatar: user.avatar || '',
phone: user.phone || '',
gender: user.gender || 0
},
expires_in: 604800
})
})
nickname: user.nickname || '用户' + String(user.user_id),
avatar: user.avatar || '',
phone: user.phone || '',
gender: user.gender || 0
},
expires_in: 604800
}))
}))
router.post('/api/v1/auth/refresh', async ctx => {
const token = readBearer(ctx.headers)
if (!token) return fail(1001, 'token_expired')
router.post('/auth/refresh', wrap(async (req, res) => {
const token = readBearer(req.headers)
if (!token) return res.json(fail(1001, 'token_expired'))
let payload
try {
payload = jwt.verify(token, config.jwt.secret)
} catch (err) {
if (err.name === 'TokenExpiredError') {
try {
payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true })
} catch (_) {
return fail(1001, 'token_expired')
}
const now = Math.floor(Date.now() / 1000)
const gracePeriod = 3 * 24 * 60 * 60
if (now - payload.exp > gracePeriod) {
return fail(1001, 'token_expired')
}
} else {
return fail(1001, 'token_expired')
let payload
try {
payload = jwt.verify(token, config.jwt.secret)
} catch (err) {
if (err.name === 'TokenExpiredError') {
try {
payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true })
} catch (_) {
return res.json(fail(1001, 'token_expired'))
}
const now = Math.floor(Date.now() / 1000)
const gracePeriod = 3 * 24 * 60 * 60
if (now - payload.exp > gracePeriod) {
return res.json(fail(1001, 'token_expired'))
}
} else {
return res.json(fail(1001, 'token_expired'))
}
}
if (payload.type !== 'user') return fail(1001, 'token_expired')
if (payload.type !== 'user') return res.json(fail(1001, 'token_expired'))
// Check if token is within 7 days of expiry (for non-expired tokens)
const now = Math.floor(Date.now() / 1000)
const sevenDays = 7 * 24 * 60 * 60
if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) {
return ok({ token, expires_in: payload.exp - now })
}
const now = Math.floor(Date.now() / 1000)
const sevenDays = 7 * 24 * 60 * 60
if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) {
return res.json(ok({ token, expires_in: payload.exp - now }))
}
const user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
if (!user) return fail(1001, 'token_expired')
const user = await userDao.findById(payload.user_id)
if (!user) return res.json(fail(1001, 'token_expired'))
const newToken = signUser(user)
return ok({ token: newToken, expires_in: 604800 })
})
}
const newToken = signUser(user)
res.json(ok({ token: newToken, expires_in: 604800 }))
}))
module.exports = register
module.exports = router
+124 -165
查看文件
@@ -1,176 +1,135 @@
const { one, query, transaction } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { requireUser, randomHex } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const { requireUser } = require('../middleware/auth')
const { randomHex } = require('../lib/auth')
const bindingDao = require('../dao/binding.dao')
const deviceDao = require('../dao/device.dao')
const commandDao = require('../dao/command.dao')
const subscriptionDao = require('../dao/subscription.dao')
const deviceEventDao = require('../dao/device-event.dao')
const logDao = require('../dao/log.dao')
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
async function ensureTrial(conn, userId) {
const [subs] = await conn.execute('SELECT subscription_id FROM subscriptions WHERE user_id = ? AND status = 1 AND expire_time > NOW() LIMIT 1', [userId])
if (subs.length > 0) return
await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, start_time, expire_time) VALUES (?, ?, 1, 0, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', [userId, 'trial'])
}
router.post('/device/bind', requireUser, wrap(async (req, res) => {
const deviceId = String(req.body.device_id || '').trim()
if (!deviceId) return res.json(fail(2001, 'device_id required'))
function register(router) {
router.post('/api/v1/device/bind', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const deviceId = String(ctx.body.device_id || '').trim()
if (!deviceId) return fail(2001, 'device_id required')
const active = await bindingDao.findActiveByUser(req.user.user_id)
if (active) return res.json(fail(2001, '已绑定设备', { device_id: active.device_id }))
const result = await transaction(async conn => {
const [active] = await conn.execute('SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', [user.user_id])
if (active.length > 0) return { duplicated: true, device_id: active[0].device_id }
const device = await bindingDao.findDeviceExists(deviceId)
if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND'))
const [devices] = await conn.execute('SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', [deviceId])
if (devices.length === 0) return { invalid: true }
const bindToken = randomHex(8)
await bindingDao.createPending(req.user.user_id, deviceId, bindToken)
await logDao.write({ user_id: req.user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: req.ip })
const bindToken = randomHex(8)
await conn.execute(
'INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE), 3, NOW())',
[user.user_id, deviceId, bindToken]
)
return { device_id: deviceId, bind_token: bindToken }
})
const sub = await subscriptionDao.findActive(req.user.user_id)
res.json(ok({
device_id: deviceId,
bind_token: bindToken,
subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 }
}))
}))
if (result.invalid) return fail(1005, 'DEVICE_NOT_FOUND')
if (result.duplicated) return fail(2001, '已绑定设备', { device_id: result.device_id })
await writeLog({ user_id: user.user_id, action: 'device_bind_request', detail: '申请绑定设备: ' + deviceId, ip: ctx.ip })
const sub = await one('SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
return ok(Object.assign(result, { subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } }))
router.post('/device/bind/confirm', requireUser, wrap(async (req, res) => {
const deviceId = String(req.body.device_id || '').trim()
const bindToken = String(req.body.bind_token || '').trim()
if (!deviceId || !bindToken) return res.json(fail(2001, 'device_id and bind_token required'))
const confirmed = await bindingDao.confirmBind(req.user.user_id, deviceId, bindToken)
if (!confirmed) return res.json(fail(2001, 'bind_token invalid or expired'))
await logDao.write({ user_id: req.user.user_id, action: 'device_bind_confirm', detail: '确认绑定设备: ' + deviceId, ip: req.ip })
const sub = await subscriptionDao.findActive(req.user.user_id)
res.json(ok({
message: 'success',
subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 }
}))
}))
router.post('/device/mock-bind', requireUser, wrap(async (req, res) => {
const config = require('../config')
if (config.nodeEnv === 'production') return res.json(fail(2001, 'not available in production'))
const deviceId = String(req.body.device_id || '').trim()
if (!deviceId) return res.json(fail(2001, 'device_id required'))
const active = await bindingDao.findActiveByUser(req.user.user_id)
if (active) return res.json(fail(2001, '已绑定设备', { device_id: active.device_id }))
const device = await bindingDao.findDeviceExists(deviceId)
if (!device) return res.json(fail(1005, 'DEVICE_NOT_FOUND'))
await bindingDao.mockBind(req.user.user_id, deviceId)
await logDao.write({ user_id: req.user.user_id, action: 'device_bind_confirm', detail: '模拟绑定设备: ' + deviceId, ip: req.ip })
res.json(ok({ message: 'success', device_id: deviceId }))
}))
router.post('/device/unbind', requireUser, wrap(async (req, res) => {
const deviceId = req.body.device_id || null
await bindingDao.unbindByUser(req.user.user_id, deviceId)
await logDao.write({ user_id: req.user.user_id, action: 'device_unbind', detail: '解绑设备: ' + (deviceId || 'current'), ip: req.ip })
res.json(ok({ message: 'success' }))
}))
router.get('/device/list', requireUser, wrap(async (req, res) => {
const devices = await deviceDao.listByUser(req.user.user_id)
res.json(ok({ devices, total: devices.length }))
}))
router.get('/device/command/pending', requireUser, wrap(async (req, res) => {
const deviceId = String(req.query.device_id || '').trim()
if (!deviceId) return res.json(fail(2001, 'device_id required'))
const active = await bindingDao.findActiveByUser(req.user.user_id)
if (!active || active.device_id !== deviceId) return res.json(fail(1006, 'DEVICE_NOT_BOUND'))
const commands = await commandDao.getPending(deviceId)
if (commands.length > 0) {
await commandDao.markPulled(commands.map(c => c.command_id))
}
res.json(ok({
commands: commands.map(c => ({
seq: c.command_id,
opcode: c.opcode,
payload: typeof c.payload_json === 'string' ? JSON.parse(c.payload_json) : c.payload_json || {}
}))
}))
}))
router.post('/device/command/result', requireUser, wrap(async (req, res) => {
const commandId = parseInt(req.body.command_id || req.body.seq, 10)
const success = req.body.success !== false
if (!commandId) return res.json(fail(2001, 'command_id required'))
// commandDao.finish verifies device ownership via user binding
await commandDao.finish(commandId, success, JSON.stringify(req.body), req.user.user_id)
res.json(ok({ message: 'success' }))
}))
router.post('/device/event', requireUser, wrap(async (req, res) => {
const deviceId = String(req.body.device_id || '').trim()
if (!deviceId) return res.json(fail(2001, 'device_id required'))
const active = await bindingDao.findActiveByUser(req.user.user_id)
if (!active || active.device_id !== deviceId) return res.json(fail(1006, 'device_not_bound'))
await deviceEventDao.create({
device_id: deviceId,
user_id: req.user.user_id,
event_type: req.body.event_type || 'device_error',
error_code: req.body.error_code || null,
temperature: req.body.temperature || null,
payload: req.body
})
await logDao.write({ user_id: req.user.user_id, action: 'device_event', detail: '设备事件: ' + deviceId, ip: req.ip })
res.json(ok({ message: 'ok' }))
}))
router.post('/api/v1/device/bind/confirm', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const deviceId = String(ctx.body.device_id || '').trim()
const bindToken = String(ctx.body.bind_token || '').trim()
if (!deviceId || !bindToken) return fail(2001, 'device_id and bind_token required')
router.get('/device/:device_id', requireUser, wrap(async (req, res) => {
const device = await deviceDao.findBoundDevice(req.user.user_id, req.params.device_id)
if (!device) return res.json(fail(1006, 'DEVICE_NOT_BOUND'))
res.json(ok(device))
}))
const updated = await transaction(async conn => {
const [rows] = await conn.execute(
'SELECT binding_id FROM bindings WHERE user_id = ? AND device_id = ? AND bind_token = ? AND bind_status = 3 AND bind_expires > NOW() LIMIT 1',
[user.user_id, deviceId, bindToken]
)
if (rows.length === 0) return false
await conn.execute('UPDATE bindings SET bind_status = 1, bind_time = NOW() WHERE binding_id = ?', [rows[0].binding_id])
await ensureTrial(conn, user.user_id)
return true
})
if (!updated) return fail(2001, 'bind_token invalid or expired')
await writeLog({ user_id: user.user_id, action: 'device_bind_confirm', detail: '确认绑定设备: ' + deviceId, ip: ctx.ip })
const sub = await one('SELECT plan, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 AND expire_time > NOW() ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
return ok({ message: 'success', subscription: sub ? { plan: sub.plan, remaining_days: sub.remaining_days } : { plan: 'none', remaining_days: 0 } })
})
router.post('/api/v1/device/mock-bind', async ctx => {
const config = require('../config')
if (config.nodeEnv === 'production') return fail(2001, 'not available in production')
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const deviceId = String(ctx.body.device_id || '').trim()
if (!deviceId) return fail(2001, 'device_id required')
const result = await transaction(async conn => {
const [active] = await conn.execute('SELECT device_id FROM bindings WHERE user_id = ? AND bind_status = 1 LIMIT 1', [user.user_id])
if (active.length > 0) return { duplicated: true, device_id: active[0].device_id }
const [devices] = await conn.execute('SELECT * FROM devices WHERE device_id = ? AND status <> 4 LIMIT 1', [deviceId])
if (devices.length === 0) return { invalid: true }
await conn.execute('UPDATE bindings SET bind_status = 2 WHERE user_id = ? AND bind_status = 3', [user.user_id])
await conn.execute('INSERT INTO bindings (user_id, device_id, bind_token, bind_expires, bind_status, bind_time) VALUES (?, ?, ?, NOW(), 1, NOW())', [user.user_id, deviceId, 'mock'])
await ensureTrial(conn, user.user_id)
return { success: true }
})
if (result.invalid) return fail(1005, 'DEVICE_NOT_FOUND')
if (result.duplicated) return fail(2001, '已绑定设备', { device_id: result.device_id })
await writeLog({ user_id: user.user_id, action: 'device_bind_confirm', detail: '模拟绑定设备: ' + deviceId, ip: ctx.ip })
return ok({ message: 'success', device_id: deviceId })
})
router.post('/api/v1/device/unbind', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const deviceId = ctx.body.device_id || null
await query(
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = :user_id AND bind_status = 1 AND (:device_id IS NULL OR device_id = :device_id)',
{ user_id: user.user_id, device_id: deviceId }
)
await writeLog({ user_id: user.user_id, action: 'device_unbind', detail: '解绑设备: ' + (deviceId || 'current'), ip: ctx.ip })
return ok({ message: 'success' })
})
router.get('/api/v1/device/list', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const devices = await query(
'SELECT d.device_id, d.device_name, d.status, d.battery, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 ORDER BY b.bind_time DESC',
{ user_id: user.user_id }
)
return ok({ devices, total: devices.length })
})
router.get('/api/v1/device/command/pending', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const deviceId = String(ctx.query.device_id || '').trim()
if (!deviceId) return fail(2001, 'device_id required')
const bound = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: deviceId })
if (!bound) return fail(1006, 'DEVICE_NOT_BOUND')
const commands = await query('SELECT command_id, opcode, payload_json FROM device_commands WHERE device_id = :device_id AND status = 1 ORDER BY created_at ASC LIMIT 10', { device_id: deviceId })
if (commands.length > 0) {
await query('UPDATE device_commands SET status = 2, pulled_at = NOW() WHERE command_id IN (' + commands.map(c => Number(c.command_id)).join(',') + ')', {})
}
return ok({ commands: commands.map(c => ({ seq: c.command_id, opcode: c.opcode, payload: typeof c.payload_json === 'string' ? JSON.parse(c.payload_json) : c.payload_json || {} })) })
})
router.post('/api/v1/device/command/result', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const commandId = parseInt(ctx.body.command_id || ctx.body.seq, 10)
const success = ctx.body.success !== false
if (!commandId) return fail(2001, 'command_id required')
const cmd = await one('SELECT dc.command_id FROM device_commands dc JOIN bindings b ON b.device_id = dc.device_id AND b.user_id = :user_id AND b.bind_status = 1 WHERE dc.command_id = :command_id', { user_id: user.user_id, command_id: commandId })
if (!cmd) return fail(1006, 'device_not_bound')
await query('UPDATE device_commands SET status = :status, finished_at = NOW(), result_json = :result_json WHERE command_id = :command_id', {
command_id: commandId,
status: success ? 3 : 4,
result_json: JSON.stringify(ctx.body)
})
return ok({ message: 'success' })
})
router.get('/api/v1/device/:device_id', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const device = await one(
'SELECT d.device_id, d.device_name, d.status, d.battery, d.temperature, d.firmware_version, d.last_online_at, b.bind_time FROM bindings b JOIN devices d ON d.device_id = b.device_id WHERE b.user_id = :user_id AND b.bind_status = 1 AND b.device_id = :device_id',
{ user_id: user.user_id, device_id: ctx.params.device_id }
)
if (!device) return fail(1006, 'DEVICE_NOT_BOUND')
return ok(device)
})
router.post('/api/v1/device/event', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const deviceId = String(ctx.body.device_id || '').trim()
if (!deviceId) return fail(2001, 'device_id required')
const binding = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: deviceId })
if (!binding) return fail(1006, 'device_not_bound')
await query(
'INSERT INTO device_events (device_id, user_id, event_type, error_code, temperature, payload_json) VALUES (:device_id, :user_id, :event_type, :error_code, :temperature, :payload_json)',
{
device_id: deviceId,
user_id: user.user_id,
event_type: ctx.body.event_type || 'device_error',
error_code: ctx.body.error_code || null,
temperature: ctx.body.temperature || null,
payload_json: JSON.stringify(ctx.body)
}
)
await writeLog({ user_id: user.user_id, action: 'device_event', detail: '设备事件: ' + deviceId, ip: ctx.ip })
return ok({ message: 'ok' })
})
}
module.exports = register
module.exports = router
+48 -57
查看文件
@@ -1,64 +1,55 @@
const { one, query } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { requireUser, requireAdmin } = require('../lib/auth')
const { requireUser } = require('../middleware/auth')
const { requireAdmin } = require('../middleware/auth')
const { getObjectUrl } = require('../lib/cos')
const { writeLog } = require('../lib/log')
const firmwareDao = require('../dao/firmware.dao')
const logDao = require('../dao/log.dao')
function register(router) {
router.get('/api/v1/admin/firmware', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const rows = await query('SELECT firmware_id, version, device_type, cos_key, size_bytes, sha256, status, created_at FROM firmware_files ORDER BY created_at DESC', {})
return ok({ records: rows, total: rows.length })
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
router.get('/admin/firmware', requireAdmin, wrap(async (req, res) => {
const rows = await firmwareDao.list()
res.json(ok({ records: rows, total: rows.length }))
}))
router.post('/admin/firmware', requireAdmin, wrap(async (req, res) => {
const version = String(req.body.version || '').trim()
const cosKey = String(req.body.cos_key || '').trim()
if (!version || !cosKey) return res.json(fail(2001, 'version and cos_key required'))
const insertId = await firmwareDao.create({
version,
device_type: req.body.device_type || '',
cos_key: cosKey,
size_bytes: Number(req.body.size_bytes || 0),
sha256: req.body.sha256 || '',
status: req.body.status === 0 ? 0 : 1
})
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_firmware_create', detail: '登记固件: ' + version, ip: req.ip })
res.json(ok({ firmware_id: insertId }))
}))
router.post('/api/v1/admin/firmware', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const version = String(ctx.body.version || '').trim()
const cosKey = String(ctx.body.cos_key || '').trim()
if (!version || !cosKey) return fail(2001, 'version and cos_key required')
const result = await query(
'INSERT INTO firmware_files (version, device_type, cos_key, size_bytes, sha256, status) VALUES (:version, :device_type, :cos_key, :size_bytes, :sha256, :status)',
{
version,
device_type: ctx.body.device_type || '',
cos_key: cosKey,
size_bytes: Number(ctx.body.size_bytes || 0),
sha256: ctx.body.sha256 || '',
status: ctx.body.status === 0 ? 0 : 1
}
)
await writeLog({ admin_id: admin.admin_id, action: 'admin_firmware_create', detail: '登记固件: ' + version, ip: ctx.ip })
return ok({ firmware_id: result.insertId })
})
router.post('/admin/firmware/:firmware_id/status', requireAdmin, wrap(async (req, res) => {
const firmwareId = parseInt(req.params.firmware_id, 10)
const status = Number(req.body.status) === 1 ? 1 : 0
if (!firmwareId) return res.json(fail(2001, 'firmware_id required'))
await firmwareDao.updateStatus(firmwareId, status)
await logDao.write({ admin_id: req.admin.admin_id, action: 'admin_firmware_status', detail: '更新固件状态: ' + firmwareId + ' -> ' + status, ip: req.ip })
res.json(ok({ message: 'success' }))
}))
router.post('/api/v1/admin/firmware/:firmware_id/status', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const firmwareId = parseInt(ctx.params.firmware_id, 10)
const status = Number(ctx.body.status) === 1 ? 1 : 0
if (!firmwareId) return fail(2001, 'firmware_id required')
await query('UPDATE firmware_files SET status = :status WHERE firmware_id = :firmware_id', { status, firmware_id: firmwareId })
await writeLog({ admin_id: admin.admin_id, action: 'admin_firmware_status', detail: '更新固件状态: ' + firmwareId + ' -> ' + status, ip: ctx.ip })
return ok({ message: 'success' })
})
router.get('/firmware/latest', requireUser, wrap(async (req, res) => {
const firmware = await firmwareDao.findLatest()
if (!firmware) return res.json(ok({ has_update: false }))
const currentVersion = req.query.current_version || ''
if (currentVersion && currentVersion === firmware.version) return res.json(ok({ has_update: false }))
res.json(ok({
has_update: true,
version: firmware.version,
size_bytes: firmware.size_bytes,
sha256: firmware.sha256,
download_url: await getObjectUrl(firmware.cos_key, 600)
}))
}))
router.get('/api/v1/firmware/latest', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const firmware = await one('SELECT * FROM firmware_files WHERE status = 1 ORDER BY created_at DESC LIMIT 1', {})
if (!firmware) return ok({ has_update: false })
const currentVersion = ctx.query.current_version || ''
if (currentVersion && currentVersion === firmware.version) return ok({ has_update: false })
return ok({
has_update: true,
version: firmware.version,
size_bytes: firmware.size_bytes,
sha256: firmware.sha256,
download_url: await getObjectUrl(firmware.cos_key, 600)
})
})
}
module.exports = register
module.exports = router
+44 -50
查看文件
@@ -1,7 +1,11 @@
const { one, query, transaction } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { requireUser, requireAdmin } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const { requireUser } = require('../middleware/auth')
const { requireAdmin } = require('../middleware/auth')
const subscriptionDao = require('../dao/subscription.dao')
const logDao = require('../dao/log.dao')
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
const PLANS = {
trial: { amount: 0, days: 7 },
@@ -9,54 +13,44 @@ const PLANS = {
yearly: { amount: 899, days: 365 }
}
function register(router) {
router.get('/api/v1/subscription', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const sub = await one('SELECT *, GREATEST(DATEDIFF(expire_time, NOW()), 0) AS remaining_days FROM subscriptions WHERE user_id = :user_id AND status = 1 ORDER BY expire_time DESC LIMIT 1', { user_id: user.user_id })
if (!sub) return ok({ status: 'inactive', plan: 'none', remaining_days: 0 })
return ok({ status: sub.remaining_days > 0 ? 'active' : 'expired', plan: sub.plan, start_time: sub.start_time, expire_time: sub.expire_time, remaining_days: sub.remaining_days })
})
router.get('/subscription', requireUser, wrap(async (req, res) => {
const sub = await subscriptionDao.findActive(req.user.user_id)
if (!sub) return res.json(ok({ status: 'inactive', plan: 'none', remaining_days: 0 }))
res.json(ok({
status: sub.remaining_days > 0 ? 'active' : 'expired',
plan: sub.plan,
start_time: sub.start_time,
expire_time: sub.expire_time,
remaining_days: sub.remaining_days
}))
}))
router.post('/api/v1/subscription/purchase', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const plan = ctx.body.plan || ctx.body.plan_type
if (!PLANS[plan]) return fail(2001, 'invalid plan')
const orderId = 'ORD' + Date.now()
return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount })
})
router.post('/subscription/purchase', requireUser, wrap(async (req, res) => {
const plan = req.body.plan || req.body.plan_type
if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan'))
const orderId = 'ORD' + Date.now()
res.json(ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount }))
}))
router.post('/api/v1/subscription/trial', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const usedTrial = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = \'trial\' LIMIT 1', { user_id: user.user_id })
if (usedTrial) return fail(2001, '已使用过试用')
const activeSub = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1', { user_id: user.user_id })
if (activeSub) return fail(2001, '已有有效订阅')
const orderId = 'TRIAL' + Date.now()
await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, \'trial\', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', { user_id: user.user_id, order_id: orderId })
return ok({ status: 'active', plan: 'trial', remaining_days: 7 })
})
router.post('/subscription/trial', requireUser, wrap(async (req, res) => {
const usedTrial = await subscriptionDao.findTrial(req.user.user_id)
if (usedTrial) return res.json(fail(2001, '已使用过试用'))
const activeSub = await subscriptionDao.findActive(req.user.user_id)
if (activeSub) return res.json(fail(2001, '已有有效订阅'))
await subscriptionDao.createTrial(req.user.user_id)
res.json(ok({ status: 'active', plan: 'trial', remaining_days: 7 }))
}))
// Temporary: admin-only until payment integration
router.post('/api/v1/subscription/verify', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
const userId = ctx.body.user_id
if (!userId) return fail(2001, 'user_id required')
const plan = ctx.body.plan || ctx.body.plan_type || 'monthly'
if (!PLANS[plan]) return fail(2001, 'invalid plan')
const p = PLANS[plan]
await transaction(async conn => {
await conn.execute('UPDATE subscriptions SET status = 2 WHERE user_id = ? AND status = 1', [userId])
await conn.execute('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (?, ?, 1, ?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))', [
userId, plan, p.amount, ctx.body.order_id || 'ORD' + Date.now(), p.days
])
})
await writeLog({ admin_id: admin.admin_id, action: 'subscription_verify', detail: '订阅生效: ' + plan + ' user:' + userId, ip: ctx.ip })
return ok({ status: 'active', plan, remaining_days: p.days })
})
}
// Temporary: admin-only until payment integration
router.post('/subscription/verify', requireAdmin, wrap(async (req, res) => {
const userId = req.body.user_id
if (!userId) return res.json(fail(2001, 'user_id required'))
const plan = req.body.plan || req.body.plan_type || 'monthly'
if (!PLANS[plan]) return res.json(fail(2001, 'invalid plan'))
const p = PLANS[plan]
await subscriptionDao.purchase(userId, plan, p.amount, req.body.order_id || 'ORD' + Date.now(), p.days)
await logDao.write({ admin_id: req.admin.admin_id, action: 'subscription_verify', detail: '订阅生效: ' + plan + ' user:' + userId, ip: req.ip })
res.json(ok({ status: 'active', plan, remaining_days: p.days }))
}))
module.exports = register
module.exports = router
+51 -61
查看文件
@@ -1,68 +1,58 @@
const { one, query, limitClause } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { requireUser } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const { requireUser } = require('../middleware/auth')
const { toMysqlDate } = require('../lib/utils')
const treatmentDao = require('../dao/treatment.dao')
const bindingDao = require('../dao/binding.dao')
const logDao = require('../dao/log.dao')
function register(router) {
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
router.get('/api/v1/treatment/history', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const page = Math.max(1, parseInt(ctx.query.page, 10) || 1)
const pageSize = Math.min(Math.max(1, parseInt(ctx.query.page_size, 10) || 20), 100)
const offset = (page - 1) * pageSize
const total = await query('SELECT COUNT(*) AS total FROM treatment_records WHERE user_id = :user_id', { user_id: user.user_id })
const records = await query('SELECT * FROM treatment_records WHERE user_id = :user_id ORDER BY created_at DESC' + limitClause(pageSize, offset), { user_id: user.user_id })
return ok({ total: total[0].total, page, page_size: pageSize, records })
router.get('/treatment/history', requireUser, wrap(async (req, res) => {
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const pageSize = Math.min(Math.max(1, parseInt(req.query.page_size, 10) || 20), 100)
const offset = (page - 1) * pageSize
const { records, total } = await treatmentDao.listByUser(req.user.user_id, { pageSize, offset })
res.json(ok({ total, page, page_size: pageSize, records }))
}))
router.post('/treatment/sync', requireUser, wrap(async (req, res) => {
const d = req.body || {}
if (!d.device_id) return res.json(fail(2001, 'device_id required'))
const active = await bindingDao.findActiveByUser(req.user.user_id)
if (!active || active.device_id !== d.device_id) return res.json(fail(1006, 'device_not_bound'))
const sessionId = d.session_id || 'SESS' + Date.now()
await treatmentDao.create({
session_id: sessionId,
device_id: d.device_id,
user_id: req.user.user_id,
start_time: toMysqlDate(d.start_time),
end_time: toMysqlDate(d.end_time),
regions: Array.isArray(d.regions) ? d.regions.join(',') : String(d.regions || ''),
total_duration_ms: parseInt(d.total_duration_ms, 10) || 0,
mode: parseInt(d.mode, 10) || 0,
avg_pd: Number(d.avg_pd) || 0,
battery: d.battery == null ? null : parseInt(d.battery, 10),
temperature: d.temperature == null ? null : parseInt(d.temperature, 10),
wavelength: d.wavelength == null ? null : parseInt(d.wavelength, 10),
brightness: d.brightness == null ? null : parseInt(d.brightness, 10),
pd_json: JSON.stringify(d.pd_values || {})
})
await treatmentDao.updateDevice(
d.device_id,
d.battery == null ? null : parseInt(d.battery, 10),
d.temperature == null ? null : parseInt(d.temperature, 10)
)
await logDao.write({ user_id: req.user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: req.ip })
res.json(ok({ record_id: sessionId }))
}))
router.post('/api/v1/treatment/sync', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const d = ctx.body || {}
if (!d.device_id) return fail(2001, 'device_id required')
const binding = await one('SELECT binding_id FROM bindings WHERE user_id = :user_id AND device_id = :device_id AND bind_status = 1', { user_id: user.user_id, device_id: d.device_id })
if (!binding) return fail(1006, 'device_not_bound')
const sessionId = d.session_id || 'SESS' + Date.now()
await query(
`INSERT INTO treatment_records
(session_id, device_id, user_id, start_time, end_time, regions, total_duration_ms, mode, avg_pd, battery, temperature, wavelength, brightness, pd_json)
VALUES (:session_id, :device_id, :user_id, :start_time, :end_time, :regions, :total_duration_ms, :mode, :avg_pd, :battery, :temperature, :wavelength, :brightness, :pd_json)
ON DUPLICATE KEY UPDATE end_time = VALUES(end_time), total_duration_ms = VALUES(total_duration_ms), avg_pd = VALUES(avg_pd), battery = VALUES(battery), temperature = VALUES(temperature), pd_json = VALUES(pd_json)`,
{
session_id: sessionId,
device_id: d.device_id,
user_id: user.user_id,
start_time: toMysqlDate(d.start_time),
end_time: toMysqlDate(d.end_time),
regions: Array.isArray(d.regions) ? d.regions.join(',') : String(d.regions || ''),
total_duration_ms: parseInt(d.total_duration_ms, 10) || 0,
mode: parseInt(d.mode, 10) || 0,
avg_pd: Number(d.avg_pd) || 0,
battery: d.battery == null ? null : parseInt(d.battery, 10),
temperature: d.temperature == null ? null : parseInt(d.temperature, 10),
wavelength: d.wavelength == null ? null : parseInt(d.wavelength, 10),
brightness: d.brightness == null ? null : parseInt(d.brightness, 10),
pd_json: JSON.stringify(d.pd_values || {})
}
)
await query('UPDATE devices SET battery = COALESCE(:battery, battery), temperature = COALESCE(:temperature, temperature), last_online_at = NOW() WHERE device_id = :device_id', {
device_id: d.device_id,
battery: d.battery == null ? null : parseInt(d.battery, 10),
temperature: d.temperature == null ? null : parseInt(d.temperature, 10)
})
await writeLog({ user_id: user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: ctx.ip })
return ok({ record_id: sessionId })
})
router.get('/treatment/:record_id', requireUser, wrap(async (req, res) => {
const record = await treatmentDao.findBySession(req.params.record_id, req.user.user_id)
if (!record) return res.json(fail(1005, 'record_not_found'))
res.json(ok(record))
}))
router.get('/api/v1/treatment/:record_id', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const record = await one('SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id', { session_id: ctx.params.record_id, user_id: user.user_id })
if (!record) return fail(1005, 'record_not_found')
return ok(record)
})
}
module.exports = register
module.exports = router
+39 -46
查看文件
@@ -1,52 +1,45 @@
const { query } = require('../lib/db')
const router = require('express').Router()
const { ok, fail } = require('../lib/response')
const { requireUser } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const { requireUser } = require('../middleware/auth')
const { getPhoneNumber } = require('../lib/wechat')
const userDao = require('../dao/user.dao')
const deviceDao = require('../dao/device.dao')
const logDao = require('../dao/log.dao')
function register(router) {
router.get('/api/v1/user/profile', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const binds = await query('SELECT COUNT(*) AS total FROM bindings WHERE user_id = :user_id AND bind_status = 1', { user_id: user.user_id })
return ok({
user_id: String(user.user_id),
nickname: user.nickname || '用户' + String(user.user_id),
avatar: user.avatar || '',
phone: user.phone || '',
gender: user.gender || 0,
bind_time: null,
device_count: binds[0].total
})
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
router.get('/user/profile', requireUser, wrap(async (req, res) => {
const user = req.user
const devices = await deviceDao.listByUser(user.user_id)
res.json(ok({
user_id: String(user.user_id),
nickname: user.nickname || '用户' + String(user.user_id),
avatar: user.avatar || '',
phone: user.phone || '',
gender: user.gender || 0,
bind_time: null,
device_count: devices.length
}))
}))
router.put('/user/profile', requireUser, wrap(async (req, res) => {
await userDao.updateProfile(req.user.user_id, {
nickname: req.body.nickname || null,
avatar: req.body.avatar || req.body.avatar_url || null,
gender: req.body.gender === undefined ? null : req.body.gender
})
await logDao.write({ user_id: req.user.user_id, action: 'user_update', detail: '更新用户资料', ip: req.ip })
res.json(ok({ message: 'success' }))
}))
router.put('/api/v1/user/profile', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
await query('UPDATE users SET nickname = COALESCE(:nickname, nickname), avatar = COALESCE(:avatar, avatar), gender = COALESCE(:gender, gender) WHERE user_id = :user_id', {
user_id: user.user_id,
nickname: ctx.body.nickname || null,
avatar: ctx.body.avatar || ctx.body.avatar_url || null,
gender: ctx.body.gender === undefined ? null : ctx.body.gender
})
await writeLog({ user_id: user.user_id, action: 'user_update', detail: '更新用户资料', ip: ctx.ip })
return ok({ message: 'success' })
})
router.post('/user/phone', requireUser, wrap(async (req, res) => {
const code = String(req.body.code || '').trim()
if (!code) return res.json(fail(2001, 'phone code required'))
const phoneInfo = await getPhoneNumber(code)
if (!phoneInfo || !phoneInfo.phoneNumber) return res.json(fail(2001, 'phone authorization failed'))
await userDao.updatePhone(req.user.user_id, phoneInfo.phoneNumber)
await logDao.write({ user_id: req.user.user_id, action: 'user_phone_bind', detail: '授权手机号', ip: req.ip })
res.json(ok({ phone: phoneInfo.phoneNumber, pure_phone_number: phoneInfo.purePhoneNumber || '', country_code: phoneInfo.countryCode || '' }))
}))
router.post('/api/v1/user/phone', async ctx => {
const user = await requireUser(ctx)
if (!user) return fail(1001, 'invalid_token')
const code = String(ctx.body.code || '').trim()
if (!code) return fail(2001, 'phone code required')
const phoneInfo = await getPhoneNumber(code)
if (!phoneInfo || !phoneInfo.phoneNumber) return fail(2001, 'phone authorization failed')
await query('UPDATE users SET phone = :phone WHERE user_id = :user_id', {
user_id: user.user_id,
phone: phoneInfo.phoneNumber
})
await writeLog({ user_id: user.user_id, action: 'user_phone_bind', detail: '授权手机号', ip: ctx.ip })
return ok({ phone: phoneInfo.phoneNumber, pure_phone_number: phoneInfo.purePhoneNumber || '', country_code: phoneInfo.countryCode || '' })
})
}
module.exports = register
module.exports = router