feat(server): 扫描报告存储(scan_reports)+诊断阈值配置(diagnosis_config)+admin 编辑
- scan_reports 表+dao+3个报告接口(POST/latest/:id,requireUser) - GET /treatment/diagnosis-config 下发实时阈值(免发版调参) - admin SettingsView 加 diagnosis_config JSON 编辑器 - 注意: schema.sql 变更需手动 TencentDB 迁移
这个提交包含在:
@@ -125,6 +125,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="section-card">
|
||||||
|
<view class="section-title">智能诊断阈值(diagnosis_config)</view>
|
||||||
|
<view class="config-desc">下发给小程序的实时诊断阈值,改此处即可调参无需发版。levels 为归一化吸收阈值;problem_wavelength 将问题类型映射到波长码 IR=1,R=2,UV=3,Y=4。保存前会校验 JSON 格式。</view>
|
||||||
|
<textarea class="config-editor" v-model="diagnosisConfigText" placeholder='{"calibrated": false, ...}' />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="save-bar">
|
<view class="save-bar">
|
||||||
@@ -155,7 +161,8 @@ export default {
|
|||||||
maintenance_mode: false
|
maintenance_mode: false
|
||||||
},
|
},
|
||||||
timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London'],
|
timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London'],
|
||||||
passwordForm: { old_password: '', new_password: '', confirm_password: '' }
|
passwordForm: { old_password: '', new_password: '', confirm_password: '' },
|
||||||
|
diagnosisConfigText: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -166,15 +173,25 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const data = await get('/api/v1/admin/settings')
|
const data = await get('/api/v1/admin/settings')
|
||||||
if (data) {
|
if (data) {
|
||||||
Object.assign(this.settings, data)
|
const { diagnosis_config, ...rest } = data
|
||||||
|
Object.assign(this.settings, rest)
|
||||||
|
this.diagnosisConfigText = JSON.stringify(diagnosis_config || {}, null, 2)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onSave() {
|
async onSave() {
|
||||||
|
let diagnosisConfig
|
||||||
try {
|
try {
|
||||||
await post('/api/v1/admin/settings', this.settings)
|
diagnosisConfig = JSON.parse(this.diagnosisConfigText || '{}')
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '诊断阈值 JSON 格式错误', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = { ...this.settings, diagnosis_config: diagnosisConfig }
|
||||||
|
await post('/api/v1/admin/settings', payload)
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
@@ -303,6 +320,26 @@ export default {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Menlo', 'Consolas', monospace;
|
||||||
|
color: #333;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-list {
|
.toggle-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+20
-1
@@ -84,6 +84,24 @@ CREATE TABLE IF NOT EXISTS treatment_records (
|
|||||||
CONSTRAINT fk_treatment_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
CONSTRAINT fk_treatment_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scan_reports (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
device_id VARCHAR(32) NULL,
|
||||||
|
scanned_at DATETIME NULL,
|
||||||
|
regions JSON NULL COMMENT 'array of per-region analysis',
|
||||||
|
overall JSON NULL COMMENT 'array of overall tendencies',
|
||||||
|
recommend_mask INT NOT NULL DEFAULT 0 COMMENT 'bitmask of regions recommended for care',
|
||||||
|
recommend_plan JSON NULL COMMENT 'array of {wavelength, mask, label_key}',
|
||||||
|
raw_pd JSON NULL COMMENT 'raw PD samples for the calibration dataset (may be partial)',
|
||||||
|
calibrated TINYINT NOT NULL DEFAULT 0 COMMENT 'whether thresholds were calibrated',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_scan_reports_user_created (user_id, created_at),
|
||||||
|
KEY idx_scan_reports_device_created (device_id, created_at),
|
||||||
|
CONSTRAINT fk_scan_reports_user FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS device_events (
|
CREATE TABLE IF NOT EXISTS device_events (
|
||||||
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
device_id VARCHAR(32) NOT NULL,
|
device_id VARCHAR(32) NOT NULL,
|
||||||
@@ -185,4 +203,5 @@ INSERT IGNORE INTO system_settings (setting_key, setting_value) VALUES
|
|||||||
('enable_register', 'true'),
|
('enable_register', 'true'),
|
||||||
('enable_binding', 'true'),
|
('enable_binding', 'true'),
|
||||||
('enable_free_mode', 'true'),
|
('enable_free_mode', 'true'),
|
||||||
('maintenance_mode', 'false');
|
('maintenance_mode', 'false'),
|
||||||
|
('diagnosis_config', '{"calibrated": false, "note": "占位阈值,待真机标定后调整", "levels": {"low": 0.15, "mid": 0.35, "high": 0.55}, "problem_wavelength": {"aging": 2, "acne": 3, "pigment": 4, "deep": 1}}');
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const { query, one, limitClause } = require('../lib/db')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a scan report
|
||||||
|
* @param {Object} report
|
||||||
|
* @param {number} report.user_id
|
||||||
|
* @param {string|null} report.device_id
|
||||||
|
* @param {string|null} report.scanned_at - MySQL datetime string
|
||||||
|
* @param {string} report.regions - JSON string (array)
|
||||||
|
* @param {string} report.overall - JSON string (array)
|
||||||
|
* @param {number} report.recommend_mask
|
||||||
|
* @param {string} report.recommend_plan - JSON string (array)
|
||||||
|
* @param {string} report.raw_pd - JSON string
|
||||||
|
* @param {number} report.calibrated - 0/1
|
||||||
|
* @returns {Promise<Object>} query result (has insertId)
|
||||||
|
*/
|
||||||
|
async function create(report) {
|
||||||
|
return query(
|
||||||
|
`INSERT INTO scan_reports
|
||||||
|
(user_id, device_id, scanned_at, regions, overall, recommend_mask, recommend_plan, raw_pd, calibrated)
|
||||||
|
VALUES (:user_id, :device_id, :scanned_at, :regions, :overall, :recommend_mask, :recommend_plan, :raw_pd, :calibrated)`,
|
||||||
|
report
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a scan report by id
|
||||||
|
* @param {number} id
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async function findById(id) {
|
||||||
|
return one('SELECT * FROM scan_reports WHERE id = :id', { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the latest scan report for a user, optionally filtered by device
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {string} [deviceId]
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async function findLatestByUser(userId, deviceId) {
|
||||||
|
if (deviceId) {
|
||||||
|
return one(
|
||||||
|
'SELECT * FROM scan_reports WHERE user_id = :user_id AND device_id = :device_id ORDER BY created_at DESC, id DESC LIMIT 1',
|
||||||
|
{ user_id: userId, device_id: deviceId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return one(
|
||||||
|
'SELECT * FROM scan_reports WHERE user_id = :user_id ORDER BY created_at DESC, id DESC LIMIT 1',
|
||||||
|
{ user_id: userId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List scan reports 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 scan_reports WHERE user_id = :user_id',
|
||||||
|
{ user_id: userId }
|
||||||
|
)
|
||||||
|
const records = await query(
|
||||||
|
'SELECT * FROM scan_reports WHERE user_id = :user_id ORDER BY created_at DESC, id DESC' + limitClause(pageSize, offset),
|
||||||
|
{ user_id: userId }
|
||||||
|
)
|
||||||
|
return { records, total: total[0].total }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
create,
|
||||||
|
findById,
|
||||||
|
findLatestByUser,
|
||||||
|
listByUser
|
||||||
|
}
|
||||||
@@ -257,7 +257,7 @@ router.get('/settings', requireAdmin, wrap(async (req, res) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
router.post('/settings', requireAdmin, wrap(async (req, res) => {
|
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']
|
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', 'diagnosis_config']
|
||||||
for (const key of Object.keys(req.body || {})) {
|
for (const key of Object.keys(req.body || {})) {
|
||||||
if (!ALLOWED_KEYS.includes(key)) continue
|
if (!ALLOWED_KEYS.includes(key)) continue
|
||||||
await settingsDao.update(key, req.body[key])
|
await settingsDao.update(key, req.body[key])
|
||||||
|
|||||||
@@ -3,11 +3,21 @@ const { ok, fail } = require('../lib/response')
|
|||||||
const { requireUser } = require('../middleware/auth')
|
const { requireUser } = require('../middleware/auth')
|
||||||
const { toMysqlDate } = require('../lib/utils')
|
const { toMysqlDate } = require('../lib/utils')
|
||||||
const treatmentDao = require('../dao/treatment.dao')
|
const treatmentDao = require('../dao/treatment.dao')
|
||||||
|
const scanReportDao = require('../dao/scan-report.dao')
|
||||||
const bindingDao = require('../dao/binding.dao')
|
const bindingDao = require('../dao/binding.dao')
|
||||||
const logDao = require('../dao/log.dao')
|
const logDao = require('../dao/log.dao')
|
||||||
|
const { getSettings } = require('../lib/settings-cache')
|
||||||
|
|
||||||
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next)
|
||||||
|
|
||||||
|
// Fallback used if diagnosis_config has not been seeded in system_settings yet
|
||||||
|
const DEFAULT_DIAGNOSIS_CONFIG = {
|
||||||
|
calibrated: false,
|
||||||
|
note: '占位阈值,待真机标定后调整',
|
||||||
|
levels: { low: 0.15, mid: 0.35, high: 0.55 },
|
||||||
|
problem_wavelength: { aging: 2, acne: 3, pigment: 4, deep: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/treatment/history', requireUser, wrap(async (req, res) => {
|
router.get('/treatment/history', requireUser, wrap(async (req, res) => {
|
||||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
|
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 pageSize = Math.min(Math.max(1, parseInt(req.query.page_size, 10) || 20), 100)
|
||||||
@@ -49,6 +59,43 @@ router.post('/treatment/sync', requireUser, wrap(async (req, res) => {
|
|||||||
res.json(ok({ record_id: sessionId }))
|
res.json(ok({ record_id: sessionId }))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// --- Scan reports ---
|
||||||
|
|
||||||
|
router.post('/treatment/report', requireUser, wrap(async (req, res) => {
|
||||||
|
const d = req.body || {}
|
||||||
|
const result = await scanReportDao.create({
|
||||||
|
user_id: req.user.user_id,
|
||||||
|
device_id: d.device_id == null ? null : String(d.device_id),
|
||||||
|
scanned_at: toMysqlDate(d.scanned_at),
|
||||||
|
regions: JSON.stringify(d.regions || []),
|
||||||
|
overall: JSON.stringify(d.overall || []),
|
||||||
|
recommend_mask: parseInt(d.recommend_mask, 10) || 0,
|
||||||
|
recommend_plan: JSON.stringify(d.recommend_plan || []),
|
||||||
|
raw_pd: JSON.stringify(d.raw_pd || {}),
|
||||||
|
calibrated: d.calibrated ? 1 : 0
|
||||||
|
})
|
||||||
|
await logDao.write({ user_id: req.user.user_id, action: 'scan_report_create', detail: '保存扫描报告: #' + result.insertId, ip: req.ip })
|
||||||
|
res.json(ok({ report_id: result.insertId }))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.get('/treatment/report/latest', requireUser, wrap(async (req, res) => {
|
||||||
|
const report = await scanReportDao.findLatestByUser(req.user.user_id, req.query.device_id)
|
||||||
|
res.json(ok(report))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.get('/treatment/report/:id', requireUser, wrap(async (req, res) => {
|
||||||
|
const report = await scanReportDao.findById(req.params.id)
|
||||||
|
if (!report || String(report.user_id) !== String(req.user.user_id)) return res.json(fail(1005, 'record_not_found'))
|
||||||
|
res.json(ok(report))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// --- Server-driven diagnosis thresholds ---
|
||||||
|
|
||||||
|
router.get('/treatment/diagnosis-config', requireUser, wrap(async (req, res) => {
|
||||||
|
const settings = await getSettings()
|
||||||
|
res.json(ok(settings.diagnosis_config || DEFAULT_DIAGNOSIS_CONFIG))
|
||||||
|
}))
|
||||||
|
|
||||||
router.get('/treatment/:record_id', requireUser, wrap(async (req, res) => {
|
router.get('/treatment/:record_id', requireUser, wrap(async (req, res) => {
|
||||||
const record = await treatmentDao.findBySession(req.params.record_id, req.user.user_id)
|
const record = await treatmentDao.findBySession(req.params.record_id, req.user.user_id)
|
||||||
if (!record) return res.json(fail(1005, 'record_not_found'))
|
if (!record) return res.json(fail(1005, 'record_not_found'))
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户