Compare commits
48 Commits
7d1b1f9c7c
...
feat/media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c88f1573a5 | ||
|
|
15b6bec215 | ||
|
|
5d256fbf52 | ||
|
|
984fca627b | ||
|
|
288c73fd14 | ||
|
|
c814a4a8f3 | ||
|
|
a78673ef41 | ||
|
|
c87760f938 | ||
|
|
75f0dc4354 | ||
|
|
1945ef3f78 | ||
|
|
ffbe5a797f | ||
|
|
6457c53d9c | ||
|
|
3351c68d10 | ||
|
|
57192b2ec0 | ||
|
|
3d683dfe82 | ||
|
|
ee5ae9e1fb | ||
|
|
01a0fffc43 | ||
|
|
976b9d94a0 | ||
|
|
5d61f19966 | ||
|
|
1982698b79 | ||
|
|
76a89dc7de | ||
|
|
201a91580c | ||
|
|
a5c67d6bec | ||
|
|
958110cc73 | ||
|
|
13705a3eaf | ||
|
|
92ffd8cecb | ||
|
|
6d073840aa | ||
|
|
f96e88b17b | ||
|
|
dc5d689d11 | ||
|
|
695b61f850 | ||
|
|
8d3b3a0491 | ||
|
|
bc3c056c8d | ||
|
|
3e36e31cf6 | ||
|
|
ec404a3e25 | ||
|
|
7924768df3 | ||
|
|
ac9896d375 | ||
|
|
a86219c8a0 | ||
|
|
432c5d96f2 | ||
|
|
aa6d93129d | ||
|
|
9a67bf80c1 | ||
|
|
03ead44385 | ||
|
|
ddf5c196e4 | ||
|
|
23cd0b14a7 | ||
|
|
803a27fb84 | ||
|
|
a4d09269a4 | ||
|
|
b0323ec89c | ||
|
|
2324d770bc | ||
|
|
823d69a3c3 |
@@ -46,6 +46,47 @@ jobs:
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
|
||||
# PP-10: 覆盖率 baseline(软门禁阶段)
|
||||
# 当前 continue-on-error=true,先让覆盖率可见、生成报告 artifact。
|
||||
# 后续根据 baseline 真实数据提高 fail-under 阈值(目标 service 层 ≥60%)并去掉
|
||||
# continue-on-error 硬化门禁。见 docs/discussions/2026-06-25-analysis/ PP-10。
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: erp_test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Run coverage (fail-under 20% baseline)
|
||||
run: cargo tarpaulin --workspace --out Xml --output-dir coverage --fail-under 20 -- --test-threads=2
|
||||
env:
|
||||
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
if-no-files-found: warn
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: 123123
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
@@ -28,9 +28,9 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
|
||||
TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||
JWT_SECRET: test-jwt-secret-for-ci
|
||||
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/erp_ci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Security audit (npm)
|
||||
run: npx npm-audit --audit-level=high || true
|
||||
run: npx npm-audit --audit-level=high
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -82,6 +82,28 @@ tmp/
|
||||
screenshots/
|
||||
server-log.txt
|
||||
snapshot_*.txt
|
||||
_*.txt
|
||||
_server_*.txt
|
||||
tmp_*.txt
|
||||
direct_*.txt
|
||||
server_*.txt
|
||||
server_combined.txt
|
||||
out.txt
|
||||
_wx_login.json
|
||||
.claude/settings.json
|
||||
|
||||
# Trace/debug JSON
|
||||
trace-*.json
|
||||
|
||||
# Graphify knowledge graph (regenerated locally)
|
||||
graphify-out/
|
||||
|
||||
# Native miniprogram (separate project)
|
||||
apps/mp-native/
|
||||
|
||||
# Misc untracked
|
||||
err.txt
|
||||
uploads/g:/hms/.superpowers/
|
||||
.claude/skills/design-handoff/node_modules/
|
||||
.design/config.yml
|
||||
.superpowers/
|
||||
|
||||
863
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
863
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
* Veepoo M2 原生小程序页面 — 连接 + 测量
|
||||
*
|
||||
* 完全脱离 Taro 框架,直接使用微信原生 API + Veepoo SDK。
|
||||
* 流程严格对齐官方 Demo:
|
||||
* onLoad 注册全局监听器
|
||||
* → scan → stopScan → connect(等待 connection:true)
|
||||
* → delay 500ms → authenticate
|
||||
* → SDK 事件(type=1) / Storage 轮询 → ready
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk');
|
||||
|
||||
// ── 常量 ──
|
||||
|
||||
var SDK_EVENT_AUTH = 1;
|
||||
var SDK_EVENT_BATTERY = 2;
|
||||
var SDK_EVENT_SLEEP = 4;
|
||||
var SDK_EVENT_DAILY = 5;
|
||||
var SDK_EVENT_TEMPERATURE = 6;
|
||||
var SDK_EVENT_BLOOD_PRESSURE = 18;
|
||||
var SDK_EVENT_BLOOD_OXYGEN = 31;
|
||||
var SDK_EVENT_HEART_RATE = 51;
|
||||
var SDK_EVENT_PRESSURE = 58;
|
||||
var SDK_EVENT_AUTO_TEST = 54;
|
||||
|
||||
var MEASURE_TYPES = [
|
||||
{ type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE },
|
||||
{ type: 'blood_oxygen', label: '血氧', unit: '%', icon: 'O₂', color: '#3B82F6', sdkType: SDK_EVENT_BLOOD_OXYGEN },
|
||||
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', icon: '↕', color: '#8B5CF6', sdkType: SDK_EVENT_BLOOD_PRESSURE },
|
||||
{ type: 'temperature', label: '体温', unit: '°C', icon: 'T', color: '#F59E0B', sdkType: SDK_EVENT_TEMPERATURE },
|
||||
{ type: 'pressure', label: '压力', unit: '', icon: '~', color: '#6366F1', sdkType: SDK_EVENT_PRESSURE },
|
||||
];
|
||||
|
||||
var MEASURE_TIMEOUTS = {
|
||||
heart_rate: 60000,
|
||||
blood_oxygen: 60000,
|
||||
blood_pressure: 120000,
|
||||
temperature: 60000,
|
||||
pressure: 90000,
|
||||
};
|
||||
|
||||
var MEASURE_SETTLE_DELAY = 1500;
|
||||
|
||||
function _findConfig(type) {
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
if (MEASURE_TYPES[i].type === type) return MEASURE_TYPES[i];
|
||||
}
|
||||
return MEASURE_TYPES[0];
|
||||
}
|
||||
|
||||
// ── Page ──
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
Page({
|
||||
data: {
|
||||
phase: 'idle',
|
||||
deviceId: '',
|
||||
deviceName: 'M2',
|
||||
batteryLevel: null,
|
||||
error: '',
|
||||
selectedType: 'heart_rate',
|
||||
selectedIcon: '♥',
|
||||
selectedColor: '#EF4444',
|
||||
selectedLabel: '心率',
|
||||
selectedUnit: 'bpm',
|
||||
measureTypes: MEASURE_TYPES,
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
results: {},
|
||||
hasResults: false,
|
||||
// 自动测量状态
|
||||
autoMeasuring: false,
|
||||
autoMeasureDone: false,
|
||||
autoMeasureStatus: {},
|
||||
autoMeasureValues: {},
|
||||
autoMeasureProgress: 0,
|
||||
},
|
||||
|
||||
_authTimer: null,
|
||||
_authTimeout: null,
|
||||
_scanTimer: null,
|
||||
_scanFound: null,
|
||||
_measureTimer: null,
|
||||
_settleTimer: null,
|
||||
_lastValues: null,
|
||||
_connected: false,
|
||||
_eventChannel: null,
|
||||
_connecting: false,
|
||||
_listenersRegistered: false,
|
||||
_autoQueue: null,
|
||||
_autoQueueIndex: 0,
|
||||
|
||||
// ── 生命周期 ──
|
||||
|
||||
onLoad: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
this._eventChannel = this.getOpenerEventChannel();
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setNavigationBarTitle({ title: 'M2 手环测量' });
|
||||
this._updateSelectedDisplay('heart_rate');
|
||||
|
||||
// 注意:不在 onLoad 注册 veepooWeiXinSDKNotifyMonitorValueChange!
|
||||
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化。
|
||||
// onLoad 时适配器未初始化 → 返回 "notifyBLECharacteristicValueChange:fail:not init"
|
||||
// 监听器改在 _doConnect 的 connection:true 回调中注册。
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 页面已加载');
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
this._cleanup();
|
||||
},
|
||||
|
||||
_cleanup: function () {
|
||||
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
if (this._scanTimer) { clearTimeout(this._scanTimer); this._scanTimer = null; }
|
||||
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
|
||||
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
|
||||
this._connecting = false;
|
||||
if (this._connected) {
|
||||
try { veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(function () {}); } catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 断开异常:', e);
|
||||
}
|
||||
this._connected = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 全局监听器(onLoad 注册一次) ──
|
||||
|
||||
_registerGlobalListeners: function () {
|
||||
if (this._listenersRegistered) return;
|
||||
this._listenersRegistered = true;
|
||||
var self = this;
|
||||
|
||||
// SDK 数据监听 — 接收所有解析后的事件(auth/measure/battery 等)
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] SDK 数据事件:', JSON.stringify(data).substring(0, 500));
|
||||
self._handleSdkEvent(data);
|
||||
});
|
||||
|
||||
// BLE 连接状态变化
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接状态变化:', JSON.stringify(res));
|
||||
if (!res.connected) {
|
||||
self._connected = false;
|
||||
self._connecting = false;
|
||||
self._cancelPendingMeasure();
|
||||
self.setData({ phase: 'disconnected' });
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] SDK 函数类型:', {
|
||||
scanFn: typeof veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice,
|
||||
connectFn: typeof veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager,
|
||||
dataFn: typeof veepooBle.veepooWeiXinSDKNotifyMonitorValueChange,
|
||||
authFn: typeof veepooFeature.veepooBlePasswordCheckManager,
|
||||
});
|
||||
},
|
||||
|
||||
_updateSelectedDisplay: function (type) {
|
||||
var cfg = _findConfig(type);
|
||||
this.setData({
|
||||
selectedType: type,
|
||||
selectedIcon: cfg.icon,
|
||||
selectedColor: cfg.color,
|
||||
selectedLabel: cfg.label,
|
||||
selectedUnit: cfg.unit,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 连接流程 ──
|
||||
|
||||
handleConnect: function () {
|
||||
if (this.data.phase !== 'idle' && this.data.phase !== 'error' && this.data.phase !== 'disconnected') return;
|
||||
if (this._connecting) return;
|
||||
this._connecting = true;
|
||||
|
||||
this.setData({ phase: 'scanning', error: '' });
|
||||
veepooLogger.setLevel(0);
|
||||
|
||||
var self = this;
|
||||
self._scanFound = null;
|
||||
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
|
||||
var device = Array.isArray(res) ? res[0] : res;
|
||||
if (!device) return;
|
||||
var name = (device.localName || device.name || '').toUpperCase();
|
||||
var deviceId = device.deviceId || device.mac || '';
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 扫描到:', name, deviceId);
|
||||
if (!self._scanFound && (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1)) {
|
||||
self._scanFound = device;
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
|
||||
self._doConnect(device);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this._scanTimer = setTimeout(function () {
|
||||
if (!self._scanFound) {
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {});
|
||||
self._connecting = false;
|
||||
self.setData({ phase: 'error', error: '未找到 M2 设备,请确保手环已开机且蓝牙已开启' });
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
_doConnect: function (device) {
|
||||
this.setData({ phase: 'connecting' });
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开始连接:', device.deviceId || device.mac);
|
||||
var self = this;
|
||||
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接阶段回调:', JSON.stringify(result).substring(0, 300));
|
||||
|
||||
// 只响应最终就绪回调(connection:true)
|
||||
if (result.connection === true) {
|
||||
self._connected = true;
|
||||
self._connecting = false;
|
||||
self.setData({
|
||||
deviceId: device.deviceId || device.mac || '',
|
||||
});
|
||||
|
||||
// 关键:在连接就绪后注册数据监听器
|
||||
// veepooWeiXinSDKNotifyMonitorValueChange 内部会调用
|
||||
// wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化+已连接
|
||||
self._registerGlobalListeners();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接就绪,监听器已注册,500ms 后发送认证');
|
||||
|
||||
setTimeout(function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 调用 veepooBlePasswordCheckManager');
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
self.setData({ phase: 'authenticating' });
|
||||
}, 500);
|
||||
|
||||
// Storage 轮询兜底
|
||||
self._authTimer = setInterval(function () {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
var status = wx.getStorageSync('deviceChipStatus');
|
||||
// SDK 可能写入字符串或布尔值 true
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
|
||||
clearInterval(self._authTimer);
|
||||
self._authTimer = null;
|
||||
self._onReady();
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}, 500);
|
||||
|
||||
self._authTimeout = setTimeout(function () {
|
||||
if (self._authTimer) {
|
||||
clearInterval(self._authTimer);
|
||||
self._authTimer = null;
|
||||
self._connecting = false;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.error('[veepoo-native] 认证超时 deviceChipStatus=', wx.getStorageSync('deviceChipStatus'));
|
||||
self.setData({ phase: 'error', error: '设备认证超时,请重新连接' });
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_onReady: function () {
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
this._connecting = false;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 认证成功,设备就绪');
|
||||
this.setData({ phase: 'ready' });
|
||||
veepooFeature.veepooReadElectricQuantityManager();
|
||||
|
||||
// 认证成功后自动读取 3 天睡眠数据 + 开启自动测量
|
||||
this._readSleepData();
|
||||
this._enableAutoMeasurement();
|
||||
|
||||
// 自动依次测量所有指标(面向中老年人,减少操作)
|
||||
this._startAutoMeasureQueue();
|
||||
},
|
||||
|
||||
// ── SDK 事件路由 ──
|
||||
|
||||
_handleSdkEvent: function (data) {
|
||||
if (!data || data.type === undefined) return;
|
||||
var type = data.type;
|
||||
|
||||
if (type === SDK_EVENT_AUTH) {
|
||||
var content = data.content || {};
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 认证事件: VPDeviceAck=' + content.VPDeviceAck + ' VPDevicepassword=' + content.VPDevicepassword);
|
||||
// VPDeviceAck 是认证结果(successfulVerification/passTheVerification)
|
||||
// VPDevicepassword 是设备密码原始值(如 "0000"),不是认证结果
|
||||
if (content.VPDeviceAck === 'successfulVerification' || content.VPDeviceAck === 'passTheVerification') {
|
||||
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
this._onReady();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === SDK_EVENT_BATTERY) {
|
||||
var pct = (data.content || {}).VPDeviceElectricPercent;
|
||||
if (pct !== undefined && pct !== null) {
|
||||
this.setData({ batteryLevel: Number(pct) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 睡眠数据回调(type=4)
|
||||
if (type === SDK_EVENT_SLEEP) {
|
||||
this._handleSleepEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 日常数据回调(type=5)
|
||||
if (type === SDK_EVENT_DAILY) {
|
||||
// 日常数据用于历史同步,原生页面暂不处理
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动测量配置回调(type=54)
|
||||
if (type === SDK_EVENT_AUTO_TEST) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量配置回调');
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
if (MEASURE_TYPES[i].sdkType === type) {
|
||||
this._handleMeasureEvent(MEASURE_TYPES[i].type, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 测量流程 ──
|
||||
|
||||
handleSelectType: function (e) {
|
||||
var type = e.currentTarget.dataset.type;
|
||||
if (this.data.measurePhase === 'measuring') return;
|
||||
this._updateSelectedDisplay(type);
|
||||
this.setData({ measureError: '' });
|
||||
},
|
||||
|
||||
handleStartMeasure: function () {
|
||||
var type = this.data.selectedType;
|
||||
if (this.data.measurePhase === 'measuring') return;
|
||||
if (!this._connected) {
|
||||
this.setData({ measureError: '设备未连接' });
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
self._lastValues = null;
|
||||
|
||||
self.setData({
|
||||
measurePhase: 'measuring',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
});
|
||||
|
||||
self._sendMeasureCommand(type, true);
|
||||
|
||||
self._measureTimer = setTimeout(function () {
|
||||
self._onMeasureError('测量超时,请重试');
|
||||
}, MEASURE_TIMEOUTS[type] || 60000);
|
||||
},
|
||||
|
||||
handleCancelMeasure: function () {
|
||||
this._cancelPendingMeasure();
|
||||
this.setData({
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
});
|
||||
},
|
||||
|
||||
handleDisconnect: function () {
|
||||
this._cleanup();
|
||||
this.setData({ phase: 'idle', deviceId: '', batteryLevel: null, error: '' });
|
||||
},
|
||||
|
||||
handleBack: function () {
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (this._eventChannel) {
|
||||
this._eventChannel.emit('measureComplete', { results: results, count: Object.keys(results).length });
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.navigateBack({ delta: 1 });
|
||||
},
|
||||
|
||||
handleResetResult: function () {
|
||||
var type = this.data.selectedType;
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
delete newResults[type];
|
||||
this.setData({
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
results: newResults,
|
||||
hasResults: Object.keys(newResults).length > 0,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 测量事件处理 ──
|
||||
|
||||
_handleMeasureEvent: function (type, data) {
|
||||
// 自动测量模式下,路由到自动测量处理器
|
||||
if (this.data.autoMeasuring) {
|
||||
this._handleAutoMeasureEvent(type, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 手动测量模式
|
||||
if (this.data.selectedType !== type || this.data.measurePhase !== 'measuring') return;
|
||||
|
||||
var content = data.content || {};
|
||||
var self = this;
|
||||
|
||||
if (content.deviceBusy === true) { self._onMeasureError('设备正忙,请稍后重试'); return; }
|
||||
if (content.notWear === true || data.state === 6) { self._onMeasureError('请将手环佩戴到手腕上'); return; }
|
||||
if (data.state === 7) { self._onMeasureError('设备正在充电'); return; }
|
||||
if (data.state === 8) { self._onMeasureError('设备电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 2) { self._onMeasureError('设备电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 3) { self._onMeasureError('设备正在测量其他数据'); return; }
|
||||
if (type === 'pressure' && data.ack === 4) { self._onMeasureError('佩戴检测未通过'); return; }
|
||||
|
||||
var values = self._extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
self._lastValues = values;
|
||||
|
||||
var displayVal = self._formatValues(type, values);
|
||||
var progress = data.Progress !== undefined ? data.Progress : 0;
|
||||
self.setData({
|
||||
measureDisplayValue: displayVal,
|
||||
measureProgress: Math.max(progress, 0),
|
||||
});
|
||||
|
||||
if (progress >= 100) {
|
||||
self._onMeasureSuccess(type, values);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self._settleTimer) {
|
||||
self._settleTimer = setTimeout(function () {
|
||||
if (self._lastValues && self.data.measurePhase === 'measuring') {
|
||||
self._onMeasureSuccess(type, self._lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
},
|
||||
|
||||
_extractValues: function (type, content) {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
var hr = Number(content.heartRate);
|
||||
return (hr >= 30 && hr <= 250) ? { heart_rate: hr } : null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
var bo = Number(content.bloodOxygen);
|
||||
return (bo >= 70 && bo <= 100) ? { blood_oxygen: bo } : null;
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
var high = Number(content.bloodPressureHigh);
|
||||
var low = Number(content.bloodPressureLow);
|
||||
return (high > 0 && low > 0) ? { systolic: high, diastolic: low } : null;
|
||||
}
|
||||
case 'temperature': {
|
||||
var temp = Number(content.bodyTemperature);
|
||||
return (temp > 30 && temp < 45) ? { temperature: temp } : null;
|
||||
}
|
||||
case 'pressure': {
|
||||
var p = Number(content.pressure);
|
||||
return (p >= 0 && p <= 100) ? { pressure: p } : null;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
|
||||
_formatValues: function (type, values) {
|
||||
if (type === 'blood_pressure') {
|
||||
return (values.systolic != null ? values.systolic : '--') + '/' + (values.diastolic != null ? values.diastolic : '--');
|
||||
}
|
||||
var v = Object.values(values)[0];
|
||||
return (v !== undefined && v !== null) ? String(v) : '--';
|
||||
},
|
||||
|
||||
_onMeasureSuccess: function (type, values) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var result = { type: type, values: values, measuredAt: Date.now() };
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
newResults[type] = result;
|
||||
|
||||
this.setData({
|
||||
measurePhase: 'success',
|
||||
measureProgress: 100,
|
||||
measureDisplayValue: this._formatValues(type, values),
|
||||
results: newResults,
|
||||
hasResults: true,
|
||||
});
|
||||
|
||||
if (this._eventChannel) {
|
||||
this._eventChannel.emit('measureResult', result);
|
||||
}
|
||||
},
|
||||
|
||||
_onMeasureError: function (msg) {
|
||||
this._cancelPendingMeasure();
|
||||
this.setData({ measurePhase: 'error', measureError: msg });
|
||||
},
|
||||
|
||||
_cancelPendingMeasure: function () {
|
||||
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
|
||||
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
|
||||
this._lastValues = null;
|
||||
|
||||
var type = this.data.selectedType;
|
||||
if (type) this._sendMeasureCommand(type, false);
|
||||
},
|
||||
|
||||
_sendMeasureCommand: function (type, on) {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: !!on });
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: on ? 'start' : 'stop' });
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: on ? 'start' : 'stop' });
|
||||
break;
|
||||
case 'temperature':
|
||||
veepooFeature.veepooSendTemperatureMeasurementSwitchManager({ switch: !!on });
|
||||
break;
|
||||
case 'pressure':
|
||||
veepooFeature.veepooSendPressureTestManager({ switch: !!on });
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 睡眠数据读取 ──
|
||||
|
||||
_sleepResults: null,
|
||||
_sleepDay: 0,
|
||||
|
||||
_readSleepData: function () {
|
||||
this._sleepResults = [];
|
||||
this._sleepDay = 0;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开始读取睡眠数据(3天)');
|
||||
|
||||
// 依次读取 3 天睡眠
|
||||
var self = this;
|
||||
veepooFeature.veepooSendReadPreciseSleepManager({ day: 0 });
|
||||
|
||||
// 延迟读取后续天(避免并发冲突)
|
||||
// eslint-disable-next-line no-undef
|
||||
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 1 }); }, 3000);
|
||||
// eslint-disable-next-line no-undef
|
||||
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 2 }); }, 6000);
|
||||
},
|
||||
|
||||
_handleSleepEvent: function (data) {
|
||||
var progress = data.Progress || 0;
|
||||
if (progress < 100) return;
|
||||
|
||||
var content = data.content || {};
|
||||
var readDay = data.readDay || 0;
|
||||
var totalTime = Number(content.sleepTotalTime || 0);
|
||||
|
||||
if (totalTime <= 0) return;
|
||||
|
||||
var sleepResult = {
|
||||
day: readDay,
|
||||
deepSleepMinutes: Number(content.deepSleepTime || 0),
|
||||
lightSleepMinutes: Number(content.lightSleepTime || 0),
|
||||
totalSleepMinutes: totalTime,
|
||||
qualityScore: Number(content.sleepQuality || 0),
|
||||
fallAsleepTime: String(content.fallAsleepTime || ''),
|
||||
exitSleepTime: String(content.exitSleepTime || ''),
|
||||
};
|
||||
|
||||
if (!this._sleepResults) this._sleepResults = [];
|
||||
this._sleepResults.push(sleepResult);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 睡眠数据 day=' + readDay + ' 总时长=' + totalTime + '分钟 质量=' + sleepResult.qualityScore + '星');
|
||||
|
||||
// 保存到 Storage 供 Taro 页面读取
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_sleep_results', JSON.stringify(this._sleepResults));
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── 自动测量 ──
|
||||
|
||||
_enableAutoMeasurement: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开启自动测量功能');
|
||||
|
||||
// 开启心率自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticHRTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启心率自动监测失败:', e);
|
||||
}
|
||||
|
||||
// 开启血压自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticBPTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启血压自动监测失败:', e);
|
||||
}
|
||||
|
||||
// 开启体温自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticTemperatureTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启体温自动监测失败:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ── 自动测量队列 ──
|
||||
|
||||
_startAutoMeasureQueue: function () {
|
||||
var types = [];
|
||||
var status = {};
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
types.push(MEASURE_TYPES[i].type);
|
||||
status[MEASURE_TYPES[i].type] = 'pending';
|
||||
}
|
||||
|
||||
this._autoQueue = types;
|
||||
this._autoQueueIndex = 0;
|
||||
|
||||
this.setData({
|
||||
autoMeasuring: true,
|
||||
autoMeasureDone: false,
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureValues: {},
|
||||
autoMeasureProgress: 0,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 启动自动测量队列,共 ' + types.length + ' 项');
|
||||
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
_startNextAutoMeasure: function () {
|
||||
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) {
|
||||
this._onAutoMeasureComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = this._autoQueue[this._autoQueueIndex];
|
||||
this._updateSelectedDisplay(type);
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'measuring';
|
||||
this.setData({
|
||||
autoMeasureStatus: status,
|
||||
measurePhase: 'measuring',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量 [' + (this._autoQueueIndex + 1) + '/' + this._autoQueue.length + ']: ' + type);
|
||||
|
||||
this._lastValues = null;
|
||||
this._sendMeasureCommand(type, true);
|
||||
|
||||
var self = this;
|
||||
var timeout = MEASURE_TIMEOUTS[type] || 60000;
|
||||
this._measureTimer = setTimeout(function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 自动测量超时: ' + type);
|
||||
self._onAutoMeasureError(type, '测量超时');
|
||||
}, timeout);
|
||||
},
|
||||
|
||||
_handleAutoMeasureEvent: function (type, data) {
|
||||
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) return;
|
||||
if (type !== this._autoQueue[this._autoQueueIndex]) return;
|
||||
|
||||
var content = data.content || {};
|
||||
var self = this;
|
||||
|
||||
if (content.deviceBusy === true) { self._onAutoMeasureError(type, '设备正忙'); return; }
|
||||
if (content.notWear === true || data.state === 6) { self._onAutoMeasureError(type, '未佩戴'); return; }
|
||||
if (data.state === 7) { self._onAutoMeasureError(type, '设备充电中'); return; }
|
||||
if (data.state === 8) { self._onAutoMeasureError(type, '电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 2) { self._onAutoMeasureError(type, '电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 3) { self._onAutoMeasureError(type, '设备正忙'); return; }
|
||||
if (type === 'pressure' && data.ack === 4) { self._onAutoMeasureError(type, '未佩戴'); return; }
|
||||
|
||||
var values = self._extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
self._lastValues = values;
|
||||
|
||||
var progress = data.Progress !== undefined ? data.Progress : 0;
|
||||
self.setData({ measureProgress: Math.max(progress, 0) });
|
||||
|
||||
if (progress >= 100) {
|
||||
self._onAutoMeasureSuccess(type, values);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self._settleTimer) {
|
||||
self._settleTimer = setTimeout(function () {
|
||||
if (self._lastValues && self.data.autoMeasuring) {
|
||||
self._onAutoMeasureSuccess(type, self._lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
},
|
||||
|
||||
_onAutoMeasureSuccess: function (type, values) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var result = { type: type, values: values, measuredAt: Date.now() };
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
newResults[type] = result;
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'done';
|
||||
|
||||
var newValues = Object.assign({}, this.data.autoMeasureValues);
|
||||
newValues[type] = this._formatValues(type, values);
|
||||
|
||||
var doneCount = 0;
|
||||
var keys = Object.keys(status);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
|
||||
}
|
||||
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量完成: ' + type + ' = ' + newValues[type] + ' (' + progress + '%)');
|
||||
|
||||
this.setData({
|
||||
results: newResults,
|
||||
hasResults: true,
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureValues: newValues,
|
||||
autoMeasureProgress: progress,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
|
||||
this._autoQueueIndex++;
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
_onAutoMeasureError: function (type, msg) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'error';
|
||||
|
||||
var doneCount = 0;
|
||||
var keys = Object.keys(status);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
|
||||
}
|
||||
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 自动测量失败: ' + type + ' - ' + msg + ' (' + progress + '%)');
|
||||
|
||||
this.setData({
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureProgress: progress,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
|
||||
this._autoQueueIndex++;
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
_onAutoMeasureComplete: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量全部完成');
|
||||
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
this.setData({
|
||||
autoMeasureDone: true,
|
||||
autoMeasuring: false,
|
||||
autoMeasureProgress: 100,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
},
|
||||
|
||||
handleCancelAutoMeasure: function () {
|
||||
this._cancelPendingMeasure();
|
||||
this._autoQueue = null;
|
||||
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
this.setData({
|
||||
autoMeasuring: false,
|
||||
autoMeasureDone: false,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
},
|
||||
});
|
||||
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "M2 手环测量",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F5F4"
|
||||
}
|
||||
215
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
215
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
@@ -0,0 +1,215 @@
|
||||
<!--
|
||||
Veepoo M2 原生小程序页面 — 连接 + 测量
|
||||
设计原型: docs/design/veepoo-measure-prototype.html
|
||||
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
|
||||
-->
|
||||
|
||||
<!-- ═══ 未连接 / 错误 / 断开 ═══ -->
|
||||
<block wx:if="{{phase === 'idle' || phase === 'error' || phase === 'disconnected'}}">
|
||||
<view class="connect-screen">
|
||||
<view class="connect-anim">
|
||||
<view class="connect-ring"></view>
|
||||
<view class="connect-center">
|
||||
<text class="connect-bt">BT</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="connect-title">M2 手环健康测量</text>
|
||||
<text class="connect-hint">请确保手环已开机且蓝牙已开启</text>
|
||||
|
||||
<view wx:if="{{error}}" class="connect-error">
|
||||
<text class="connect-error-text">{{error}}</text>
|
||||
</view>
|
||||
|
||||
<view class="connect-btn-wrap">
|
||||
<view class="btn-primary" bindtap="handleConnect">
|
||||
{{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{hasResults}}" class="connect-back">
|
||||
<view class="btn-text" bindtap="handleBack">查看测量结果并返回</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 连接中(扫描/连接/认证) ═══ -->
|
||||
<block wx:elif="{{phase === 'scanning' || phase === 'connecting' || phase === 'authenticating'}}">
|
||||
<view class="connect-screen">
|
||||
<view class="connect-anim">
|
||||
<view class="connect-ring connect-ring--active"></view>
|
||||
<view class="connect-center">
|
||||
<text class="connect-bt">BT</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="connect-title">
|
||||
{{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}}
|
||||
</text>
|
||||
<text class="connect-hint">请确保手环已开机且靠近手机</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 自动测量中 / 自动测量完成 ═══ -->
|
||||
<block wx:elif="{{phase === 'ready' && (autoMeasuring || autoMeasureDone)}}">
|
||||
<view class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="device-bar">
|
||||
<view class="device-bar__left">
|
||||
<view class="device-bar__dot"></view>
|
||||
<text class="device-bar__name">{{deviceName}}</text>
|
||||
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
|
||||
</view>
|
||||
<view wx:if="{{autoMeasuring}}" class="device-bar__disconnect" bindtap="handleCancelAutoMeasure">取消</view>
|
||||
</view>
|
||||
|
||||
<view class="auto-measure">
|
||||
<!-- 标题 -->
|
||||
<view class="auto-measure__header">
|
||||
<text class="auto-measure__title">{{autoMeasureDone ? '✓ 测量完成!' : '正在自动测量...'}}</text>
|
||||
<text wx:if="{{!autoMeasureDone}}" class="auto-measure__subtitle">请保持手环佩戴,无需任何操作</text>
|
||||
</view>
|
||||
|
||||
<!-- 指标列表 -->
|
||||
<view class="auto-measure__list">
|
||||
<view
|
||||
wx:for="{{measureTypes}}"
|
||||
wx:key="type"
|
||||
class="auto-item {{autoMeasureStatus[item.type] === 'done' ? 'auto-item--done' : autoMeasureStatus[item.type] === 'measuring' ? 'auto-item--active' : autoMeasureStatus[item.type] === 'error' ? 'auto-item--error' : ''}}"
|
||||
>
|
||||
<view class="auto-item__left">
|
||||
<view class="auto-item__icon-wrap" style="background: {{autoMeasureStatus[item.type] === 'done' ? item.color : autoMeasureStatus[item.type] === 'error' ? '#ccc' : item.color}}">
|
||||
<text class="auto-item__icon">{{autoMeasureStatus[item.type] === 'done' ? '✓' : autoMeasureStatus[item.type] === 'error' ? '✕' : item.icon}}</text>
|
||||
</view>
|
||||
<text class="auto-item__label">{{item.label}}</text>
|
||||
</view>
|
||||
<view class="auto-item__right">
|
||||
<block wx:if="{{autoMeasureStatus[item.type] === 'done'}}">
|
||||
<text class="auto-item__value" style="color: {{item.color}}">{{autoMeasureValues[item.type]}}</text>
|
||||
<text wx:if="{{item.unit}}" class="auto-item__unit">{{item.unit}}</text>
|
||||
</block>
|
||||
<text wx:elif="{{autoMeasureStatus[item.type] === 'measuring'}}" class="auto-item__status auto-item__status--active">测量中...</text>
|
||||
<text wx:elif="{{autoMeasureStatus[item.type] === 'error'}}" class="auto-item__status auto-item__status--error">已跳过</text>
|
||||
<text wx:else class="auto-item__status">等待中</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view class="auto-progress">
|
||||
<view class="auto-progress__bar">
|
||||
<view class="auto-progress__fill" style="width: {{autoMeasureProgress}}%"></view>
|
||||
</view>
|
||||
<text class="auto-progress__text">{{autoMeasureProgress}}%</text>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">
|
||||
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<block wx:if="{{autoMeasureDone}}">
|
||||
<view class="btn btn--primary" bindtap="handleBack">查看结果并返回</view>
|
||||
</block>
|
||||
<block wx:elif="{{autoMeasuring}}">
|
||||
<view class="btn btn--text" bindtap="handleCancelAutoMeasure">取消自动测量</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 就绪 + 手动测量 ═══ -->
|
||||
<block wx:elif="{{phase === 'ready'}}">
|
||||
<view class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="device-bar">
|
||||
<view class="device-bar__left">
|
||||
<view class="device-bar__dot"></view>
|
||||
<text class="device-bar__name">{{deviceName}}</text>
|
||||
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
|
||||
</view>
|
||||
<view class="device-bar__disconnect" bindtap="handleDisconnect">断开</view>
|
||||
</view>
|
||||
|
||||
<!-- 指标选择器 — 药丸式 -->
|
||||
<scroll-view class="selector" scroll-x enhanced show-scrollbar="{{false}}">
|
||||
<view
|
||||
wx:for="{{measureTypes}}"
|
||||
wx:key="type"
|
||||
class="selector__pill {{selectedType === item.type ? 'selector__pill--active' : ''}} {{results[item.type] ? 'selector__pill--done' : ''}}"
|
||||
data-type="{{item.type}}"
|
||||
bindtap="handleSelectType"
|
||||
>
|
||||
<view class="selector__icon-wrap" style="background: {{item.color}}">
|
||||
<text class="selector__icon">{{item.icon}}</text>
|
||||
</view>
|
||||
<text class="selector__label">{{item.label}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 仪表盘区域 -->
|
||||
<view class="gauge-section">
|
||||
<view class="gauge {{measurePhase === 'measuring' ? 'gauge--measuring' : ''}}">
|
||||
<!-- SVG 圆环 -->
|
||||
<view class="gauge__ring-wrap">
|
||||
<view class="gauge__ring-bg"></view>
|
||||
<view class="gauge__ring-progress" style="background: conic-gradient({{selectedColor}} {{measureProgress * 3.6}}deg, #E8E2DC 0deg);"></view>
|
||||
<view class="gauge__center">
|
||||
<!-- 空闲 -->
|
||||
<block wx:if="{{measurePhase === 'idle'}}">
|
||||
<text class="gauge__icon-lg" style="color: {{selectedColor}}">{{selectedIcon}}</text>
|
||||
<text class="gauge__hint">点击下方按钮开始</text>
|
||||
</block>
|
||||
<!-- 测量中 -->
|
||||
<block wx:elif="{{measurePhase === 'measuring'}}">
|
||||
<text wx:if="{{measureDisplayValue}}" class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
|
||||
<text wx:else class="gauge__loading">测量中...</text>
|
||||
<text wx:if="{{measureDisplayValue}}" class="gauge__unit">{{selectedUnit}}</text>
|
||||
</block>
|
||||
<!-- 成功 -->
|
||||
<block wx:elif="{{measurePhase === 'success'}}">
|
||||
<text class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
|
||||
<text class="gauge__unit">{{selectedUnit}}</text>
|
||||
</block>
|
||||
<!-- 错误 -->
|
||||
<block wx:elif="{{measurePhase === 'error'}}">
|
||||
<text class="gauge__err">!</text>
|
||||
<text class="gauge__err-text">{{measureError}}</text>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="progress-bar">
|
||||
<view class="progress-bar__fill" style="width: {{measureProgress}}%; background: {{selectedColor}};"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">
|
||||
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<block wx:if="{{measurePhase === 'idle'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">
|
||||
开始测量{{selectedLabel}}
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'measuring'}}">
|
||||
<view class="btn btn--secondary" bindtap="handleCancelMeasure">停止测量</view>
|
||||
<view class="btn btn--text" bindtap="handleBack">完成并查看结果</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'success'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleResetResult">重新测量</view>
|
||||
<view class="btn btn--secondary" bindtap="handleBack">完成并查看结果</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'error'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">重新测量</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
619
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
619
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Veepoo M2 原生页面样式
|
||||
* 设计原型: docs/design/veepoo-measure-prototype.html
|
||||
* 复刻小程序 design token
|
||||
*/
|
||||
|
||||
page {
|
||||
--pri: #C4623A;
|
||||
--pri-l: #F0DDD4;
|
||||
--bg: #F5F0EB;
|
||||
--card: #FFFFFF;
|
||||
--tx: #2D2A26;
|
||||
--tx2: #5A554F;
|
||||
--tx3: #78716C;
|
||||
--bd: #E8E2DC;
|
||||
--acc: #5B7A5E;
|
||||
--acc-l: #E8F0E8;
|
||||
--dan: #B54A4A;
|
||||
--dan-l: #FDEAEA;
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
连接页面(未连接/连接中/错误)
|
||||
═══════════════════════════════════════ */
|
||||
.connect-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.connect-anim {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.connect-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--pri);
|
||||
animation: pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.connect-ring--active {
|
||||
border-color: var(--pri);
|
||||
animation: pulse-ring 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
.connect-center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--pri);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connect-bt {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connect-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.connect-hint {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-error {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-error-text {
|
||||
font-size: 14px;
|
||||
color: var(--dan);
|
||||
}
|
||||
|
||||
.connect-btn-wrap {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.connect-back {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
测量页面(就绪态)
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
/* ═══ 自动测量进度 ═══ */
|
||||
.auto-measure {
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
.auto-measure__header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.auto-measure__title {
|
||||
display: block;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auto-measure__subtitle {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-measure__list {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(45,42,38,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auto-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.auto-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.auto-item--active {
|
||||
background: rgba(196,98,58,0.04);
|
||||
}
|
||||
|
||||
.auto-item--done {
|
||||
background: rgba(91,122,94,0.04);
|
||||
}
|
||||
|
||||
.auto-item--error {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auto-item__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auto-item__icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auto-item--done .auto-item__icon-wrap {
|
||||
background: var(--acc) !important;
|
||||
}
|
||||
|
||||
.auto-item--error .auto-item__icon-wrap {
|
||||
background: var(--tx3) !important;
|
||||
}
|
||||
|
||||
.auto-item__icon {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-item__label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.auto-item__right {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-item__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-item__unit {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-item__status {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-item__status--active {
|
||||
color: var(--pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auto-item__status--error {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
/* ── 自动测量进度条 ── */
|
||||
.auto-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.auto-progress__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bd);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auto-progress__fill {
|
||||
height: 100%;
|
||||
background: var(--pri);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auto-progress__text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pri);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
.measure-page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ── 设备状态栏 ── */
|
||||
.device-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.device-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--acc);
|
||||
}
|
||||
|
||||
.device-bar__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.device-bar__battery {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.device-bar__disconnect {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ── 指标选择器(药丸式) ── */
|
||||
.selector {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
padding: 16px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selector__pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
min-width: 64px;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.selector__pill--active {
|
||||
background: var(--card);
|
||||
box-shadow: 0 2px 12px rgba(45,42,38,0.10);
|
||||
}
|
||||
|
||||
.selector__pill--done::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: var(--acc);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.selector__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.selector__pill--active .selector__icon-wrap {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.selector__icon {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.selector__label {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.selector__pill--active .selector__label {
|
||||
color: var(--tx);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 仪表盘 ── */
|
||||
.gauge-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gauge--measuring {
|
||||
animation: gauge-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gauge-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
.gauge__ring-wrap {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.gauge__ring-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 10px solid var(--bd);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gauge__ring-progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge__icon-lg {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gauge__hint {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gauge__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gauge__unit {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.gauge__loading {
|
||||
font-size: 16px;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.gauge__err {
|
||||
font-size: 36px;
|
||||
color: var(--dan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gauge__err-text {
|
||||
font-size: 13px;
|
||||
color: var(--tx2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 进度条 ── */
|
||||
.progress-bar {
|
||||
width: 240px;
|
||||
height: 4px;
|
||||
background: var(--bd);
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ── 免责声明 ── */
|
||||
.disclaimer {
|
||||
text-align: center;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disclaimer__text {
|
||||
font-size: 11px;
|
||||
color: var(--tx3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ── */
|
||||
.actions {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--pri);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--card);
|
||||
color: var(--tx);
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--tx3);
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ═══ 旧版兼容样式 ═══ */
|
||||
.btn-primary {
|
||||
background: var(--pri);
|
||||
color: #fff;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
|
||||
}
|
||||
.btn-primary:active { opacity: 0.85; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--tx);
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
.btn-secondary:active { opacity: 0.85; }
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: var(--tx3);
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-large { margin: 0; }
|
||||
|
||||
/* 旧版 header/selector/gauge 兼容 */
|
||||
.header { display: none; }
|
||||
.header-device { display: none; }
|
||||
.header-dot { display: none; }
|
||||
.header-name { display: none; }
|
||||
.header-battery { display: none; }
|
||||
.header-disconnect { display: none; }
|
||||
.selector-item { display: none; }
|
||||
.selector-icon { display: none; }
|
||||
.selector-label { display: none; }
|
||||
.selector-check { display: none; }
|
||||
.gauge-circle { display: none; }
|
||||
.gauge-icon { display: none; }
|
||||
.gauge-hint { display: none; }
|
||||
.gauge-value { display: none; }
|
||||
.gauge-loading { display: none; }
|
||||
.gauge-err { display: none; }
|
||||
.gauge-err-text { display: none; }
|
||||
.gauge-progress-bar { display: none; }
|
||||
.gauge-progress-fill { display: none; }
|
||||
.assessment { display: none; }
|
||||
.assessment-text { display: none; }
|
||||
.disclaimer-text { display: none; }
|
||||
.measure-error { display: none; }
|
||||
.measure-error-text { display: none; }
|
||||
@@ -7,6 +7,28 @@ interface RichArticleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAG_STYLE = JSON.stringify({
|
||||
h1: 'font-size:20px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h2: 'font-size:18px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h3: 'font-size:16px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h4: 'font-size:15px;font-weight:600;color:#2D2A26;margin:12px 0 6px',
|
||||
p: 'font-size:16px;color:#2D2A26;line-height:1.85;margin-bottom:12px',
|
||||
ul: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
ol: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
li: 'margin-bottom:4px',
|
||||
blockquote: 'border-left:3px solid #C4623A;padding:6px 12px;color:#5A554F;margin:12px 0',
|
||||
strong: 'font-weight:700;color:#2D2A26',
|
||||
em: 'font-style:italic',
|
||||
code: 'background:#F5F0EB;padding:2px 6px;border-radius:4px;font-size:14px;color:#C4623A',
|
||||
pre: 'background:#F5F0EB;padding:12px;border-radius:8px;margin:14px 0;overflow-x:auto',
|
||||
table: 'width:100%;border-collapse:collapse;margin:8px 0;font-size:14px',
|
||||
th: 'border:1px solid #E8E2DC;padding:6px 8px;background:#FAF8F5;font-weight:600;text-align:left',
|
||||
td: 'border:1px solid #E8E2DC;padding:6px 8px',
|
||||
hr: 'border:none;border-top:1px dashed #D1D5DB;margin:14px 0',
|
||||
img: 'max-width:100%;border-radius:8px;margin:8px 0;display:block',
|
||||
a: 'color:#C4623A;text-decoration:none',
|
||||
});
|
||||
|
||||
function prepareHtml(raw: string): string {
|
||||
return sanitizeHtml(raw);
|
||||
}
|
||||
@@ -23,7 +45,7 @@ function RichArticle({ html, className }: RichArticleProps) {
|
||||
lazy-load
|
||||
selectable
|
||||
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
|
||||
tag-style='{"img":"max-width:100%;border-radius:8px;margin:12px auto;display:block","a":"color:#C4623A;text-decoration:none"}'
|
||||
tag-style={TAG_STYLE}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,515 +2,131 @@
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.health-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.health-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 今日体征 hero 卡片 ─── */
|
||||
.vitals-grid {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: linear-gradient(135deg, $card 60%, $pri-l);
|
||||
border-radius: var(--tk-card-radius);
|
||||
box-shadow: $shadow-md;
|
||||
padding: var(--tk-card-padding);
|
||||
|
||||
/* 覆盖 ContentCard 默认 padding/margin */
|
||||
&.content-card {
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.vitals-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.vitals-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.vitals-badge {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
padding: 3px 10px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vitals-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.vital-cell {
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-md) var(--tk-gap-sm);
|
||||
border-radius: $r-sm;
|
||||
background: $bg;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vital-cell.vital-warn {
|
||||
background: $wrn-l;
|
||||
|
||||
.vital-value {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.vital-cell.vital-ok {
|
||||
.vital-value {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷入口 — 横排 4 格图标 ─── */
|
||||
.quick-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.quick-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── 分类标签 ─── */
|
||||
.health-categories {
|
||||
white-space: nowrap;
|
||||
padding: var(--tk-gap-xs) var(--tk-page-padding);
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-cat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-xs);
|
||||
min-height: var(--tk-touch-min);
|
||||
justify-content: center;
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.quick-icon-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-icon--input {
|
||||
background: $pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--trend {
|
||||
background: $doc-pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $doc-pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--report {
|
||||
background: $acc-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--med {
|
||||
background: $wrn-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 告警横幅 ─── */
|
||||
.alert-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: $dan-l;
|
||||
border-radius: $r-sm;
|
||||
|
||||
/* 覆盖 ContentCard 默认样式 */
|
||||
&.content-card {
|
||||
background: $dan-l;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
margin-right: 8px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 500;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.alert-arrow {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 400;
|
||||
color: $tx2;
|
||||
background: $surface-alt;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: var(--tk-pri);
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
padding: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: var(--tk-gap-sm) var(--tk-gap-xs);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-threshold-line {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-top: 1.5px dashed $wrn;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trend-threshold-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -16px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
/* ─── 可滚动内容区 ─── */
|
||||
.health-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* 微信小程序 ScrollView scrollY 需要显式高度 */
|
||||
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
|
||||
}
|
||||
|
||||
/* ─── 文章列表 ─── */
|
||||
.health-article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 24px;
|
||||
border-radius: $r-xs $r-xs 0 0;
|
||||
min-height: 6px;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: var(--tk-pri);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
.content-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: var(--tk-pri-l);
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
.health-article-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $acc;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.article-entry-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%);
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, $acc, $acc 60%, transparent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.ai-card-count {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
border-bottom: 1px solid rgba($acc, 0.12);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--tk-gap-xs);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
|
||||
&.ai-risk-high {
|
||||
background: $dan;
|
||||
}
|
||||
|
||||
&.ai-risk-medium {
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
&.ai-risk-low {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
.health-article-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── AI 建议反馈按钮 ─── */
|
||||
.ai-feedback-row {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-top: var(--tk-gap-xs);
|
||||
padding-left: 20px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-feedback-btn {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: $r-xs;
|
||||
@include flex-center;
|
||||
padding: 0 var(--tk-gap-sm);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.ai-feedback-adopt {
|
||||
background: rgba($acc, 0.15);
|
||||
}
|
||||
|
||||
&.ai-feedback-ignore {
|
||||
background: $surface-alt;
|
||||
}
|
||||
|
||||
&.ai-feedback-consult {
|
||||
background: var(--tk-pri-l);
|
||||
}
|
||||
.health-article-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ai-feedback-btn-text {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 500;
|
||||
.health-article-summary {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-feedback-adopt .ai-feedback-btn-text {
|
||||
color: $acc;
|
||||
.health-article-meta {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-sm);
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-feedback-consult .ai-feedback-btn-text {
|
||||
.health-article-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-pri);
|
||||
background: var(--tk-pri-l);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
.health-article-date {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode .health-page {
|
||||
.health-cat-tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.health-article-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.health-article-summary {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,244 +1,152 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import GuestGuard from '../../components/GuestGuard';
|
||||
import Loading from '../../components/Loading';
|
||||
import {
|
||||
listArticles,
|
||||
listCategories,
|
||||
listPublicArticles,
|
||||
listPublicCategories,
|
||||
type Article,
|
||||
type ArticleCategory,
|
||||
} from '../../services/article';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import SegmentTabs from '../../components/SegmentTabs';
|
||||
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview';
|
||||
import { submitSuggestionFeedback } from '../../services/ai-analysis';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ label: '录入体征', icon: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/index' },
|
||||
] as const;
|
||||
|
||||
function statusClass(status?: string): string {
|
||||
if (!status) return '';
|
||||
if (status === 'high' || status === 'abnormal') return 'vital-warn';
|
||||
if (status === 'low') return 'vital-warn';
|
||||
return 'vital-ok';
|
||||
}
|
||||
|
||||
function formatDate(): string {
|
||||
const d = new Date();
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `${month}月${day}日 周${weekDays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const modeClass = useElderClass();
|
||||
const {
|
||||
todaySummary, loading, error, activeTab, trendData, trendLoading,
|
||||
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData,
|
||||
} = useHealthOverview();
|
||||
const isLoggedIn = !!useAuthStore((s) => s.user);
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
if (!user) {
|
||||
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
</View>
|
||||
<Loading />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const vitals = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
|
||||
];
|
||||
const recordedCount = vitals.filter((v) => v.value !== '—').length;
|
||||
|
||||
const getThresholdValue = (type: VitalType): number | null => {
|
||||
if (!thresholds.length) return null;
|
||||
const th = thresholds;
|
||||
if (type === 'blood_pressure') {
|
||||
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
|
||||
return v?.threshold_value ?? 140;
|
||||
const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const cid = categoryId !== undefined ? categoryId : activeCategory;
|
||||
const res = isLoggedIn
|
||||
? await listArticles({ page: p, category_id: cid || undefined })
|
||||
: await listPublicArticles({ page: p, category_id: cid || undefined });
|
||||
const list = res.data || [];
|
||||
setArticles(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载文章列表失败:', err);
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
if (type === 'heart_rate') {
|
||||
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
|
||||
return v?.threshold_value ?? 100;
|
||||
}, [activeCategory, isLoggedIn]);
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
try {
|
||||
const cats = isLoggedIn
|
||||
? await listCategories()
|
||||
: await listPublicCategories();
|
||||
setCategories(cats || []);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载分类失败:', err);
|
||||
setCategories([]);
|
||||
}
|
||||
await fetchData(1);
|
||||
}, [fetchData, isLoggedIn]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && articles.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
if (type === 'blood_sugar') {
|
||||
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
|
||||
return v?.threshold_value ?? 6.1;
|
||||
}
|
||||
return null;
|
||||
}, [loading, articles.length, total, page, fetchData]);
|
||||
|
||||
const handleCategoryChange = (categoryId: string | null) => {
|
||||
setActiveCategory(categoryId);
|
||||
fetchData(1, false, categoryId);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
return `${month}月${day}日`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
<Text className='health-date'>{formatDate()}</Text>
|
||||
</View>
|
||||
<PageShell safeBottom={false} padding="none" scroll={false} className={`health-page ${modeClass}`}>
|
||||
{/* 分类标签 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='health-categories'>
|
||||
<View
|
||||
className={`health-cat-tab ${!activeCategory ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
>
|
||||
<Text>推荐</Text>
|
||||
</View>
|
||||
{categories.map((cat) => (
|
||||
<View
|
||||
key={cat.id}
|
||||
className={`health-cat-tab ${activeCategory === cat.id ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
>
|
||||
<Text>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 今日体征 hero 卡片 */}
|
||||
<View className='vitals-grid'>
|
||||
<View className='vitals-header'>
|
||||
<Text className='vitals-title'>今日体征</Text>
|
||||
{recordedCount > 0 && (
|
||||
<Text className='vitals-badge'>已记录 {recordedCount} 项</Text>
|
||||
)}
|
||||
</View>
|
||||
{loading ? <Loading /> : (
|
||||
<View className='vitals-row'>
|
||||
{vitals.map((v) => (
|
||||
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
|
||||
<Text className='vital-value'>{v.value}</Text>
|
||||
<Text className='vital-unit'>{v.unit}</Text>
|
||||
<Text className='vital-label'>{v.label}</Text>
|
||||
</View>
|
||||
{/* 文章列表 */}
|
||||
<ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchData(1, false, null)} />
|
||||
) : articles.length === 0 && !loading ? (
|
||||
<EmptyState text='暂无健康资讯' />
|
||||
) : (
|
||||
<View className='health-article-list'>
|
||||
{articles.map((a) => (
|
||||
<ContentCard
|
||||
key={a.id}
|
||||
padding='sm'
|
||||
margin='none'
|
||||
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${a.id}`)}
|
||||
>
|
||||
<View className='health-article-body'>
|
||||
<View className='health-article-content'>
|
||||
<Text className='health-article-title'>{a.title}</Text>
|
||||
{a.summary && (
|
||||
<Text className='health-article-summary'>{a.summary}</Text>
|
||||
)}
|
||||
<View className='health-article-meta'>
|
||||
{(a.category_name || a.category) && (
|
||||
<Text className='health-article-tag'>{a.category_name || a.category}</Text>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text className='health-article-date'>{formatDate(a.published_at)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 快捷入口 — 横排 4 格图标 */}
|
||||
<View className='quick-entries'>
|
||||
{QUICK_ENTRIES.map((e) => (
|
||||
<View
|
||||
key={e.label}
|
||||
className='quick-entry'
|
||||
onClick={() => safeNavigateTo(e.path)}
|
||||
>
|
||||
<View className={`quick-icon quick-icon--${e.color}`}>
|
||||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||||
</View>
|
||||
<Text className='quick-label'>{e.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 告警横幅 */}
|
||||
{alertCount > 0 && (
|
||||
<ContentCard
|
||||
variant="default"
|
||||
padding="sm"
|
||||
margin="none"
|
||||
className='alert-hint'
|
||||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||||
>
|
||||
<View className='alert-dot' />
|
||||
<Text className='alert-text'>{alertCount} 条待处理告警</Text>
|
||||
<Text className='alert-arrow'>›</Text>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* AI 建议 */}
|
||||
{aiSuggestions.length > 0 && (
|
||||
<View className='ai-suggestion-card'>
|
||||
<View className='ai-card-header'>
|
||||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||||
<Text className='ai-card-count'>{aiSuggestions.length} 条</Text>
|
||||
</View>
|
||||
{aiSuggestions.map((s) => {
|
||||
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
|
||||
return (
|
||||
<View key={s.id} className='ai-suggestion-item'>
|
||||
<View className='ai-suggestion-main' onClick={() => {
|
||||
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
|
||||
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
|
||||
}}>
|
||||
<View className={`ai-risk-dot ${riskCls}`} />
|
||||
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-row'>
|
||||
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>采纳</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>忽略</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>咨询医生</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 7天趋势 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
|
||||
{trendLoading ? <Loading /> : trendData.length === 0 ? (
|
||||
<ContentCard padding="md">
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padding="md">
|
||||
<View className='trend-bars'>
|
||||
{(() => {
|
||||
const tv = getThresholdValue(activeTab);
|
||||
if (tv) {
|
||||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||||
return (
|
||||
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
|
||||
<Text className='trend-threshold-label'>{tv}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const tv = getThresholdValue(activeTab);
|
||||
const isAbnormal = tv ? point.value >= tv : false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ContentCard>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 健康资讯入口 */}
|
||||
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</ContentCard>
|
||||
{loading && <Loading />}
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '健康测量',
|
||||
});
|
||||
256
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
256
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
@@ -0,0 +1,256 @@
|
||||
// Veepoo 测量结果 + 上传页样式
|
||||
// 设计原型: docs/design/veepoo-measure-prototype.html
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.vm-page {
|
||||
min-height: 100vh;
|
||||
background: var(--tk-bg-primary, $bg);
|
||||
}
|
||||
|
||||
// ── 连接中(等待跳转态) ──
|
||||
.vm-connect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0 32px;
|
||||
|
||||
&__anim {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $pri;
|
||||
animation: vm-pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
&__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__bt {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2, 22px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vm-pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
// ── 上传页面 ──
|
||||
.vm-upload {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40px;
|
||||
|
||||
&__header {
|
||||
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2, 22px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 结果卡片网格 ──
|
||||
.vm-results-grid {
|
||||
padding: 0 var(--tk-page-padding, 20px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
|
||||
.vm-result-card {
|
||||
background: $card;
|
||||
border-radius: var(--tk-card-radius, 16px);
|
||||
padding: var(--tk-gap-md, 16px);
|
||||
box-shadow: $shadow-sm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg, 34px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--tk-font-micro, 11px);
|
||||
font-weight: 500;
|
||||
|
||||
&--normal {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
padding-left: 8px;
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&--sleep {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 睡眠数据行 ──
|
||||
.vm-sleep-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 8px;
|
||||
|
||||
&__day {
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx2;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body, 16px);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__quality {
|
||||
font-size: 12px;
|
||||
color: $wrn;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 底部上传播区 ──
|
||||
.vm-upload-footer {
|
||||
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px);
|
||||
|
||||
&__hint {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
margin-bottom: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro, 11px);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
margin-top: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 长者模式 ──
|
||||
.elder-mode {
|
||||
.vm-results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vm-result-card__value {
|
||||
font-size: var(--tk-font-num-lg, 40px);
|
||||
}
|
||||
}
|
||||
306
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
306
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import type { NormalizedReading } from '@/services/ble/types';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PrimaryButton from '@/components/ui/PrimaryButton';
|
||||
import './index.scss';
|
||||
|
||||
/** 原生页面返回的测量结果格式 */
|
||||
interface NativeMeasureResult {
|
||||
type: string;
|
||||
values: Record<string, number>;
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/** 原生页面返回的睡眠数据格式 */
|
||||
interface NativeSleepResult {
|
||||
day: number;
|
||||
deepSleepMinutes: number;
|
||||
lightSleepMinutes: number;
|
||||
totalSleepMinutes: number;
|
||||
qualityScore: number;
|
||||
fallAsleepTime: string;
|
||||
exitSleepTime: string;
|
||||
}
|
||||
|
||||
/** 指标配置 */
|
||||
const METRIC_CONFIG = [
|
||||
{ type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' },
|
||||
{ type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' },
|
||||
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' },
|
||||
{ type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' },
|
||||
{ type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' },
|
||||
] as const;
|
||||
|
||||
/** 健康评估 */
|
||||
function assessHealth(type: string, values: Record<string, number>): { level: 'normal' | 'warning' | 'danger'; text: string } {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const v = values.heart_rate ?? 0;
|
||||
if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' };
|
||||
if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' };
|
||||
return { level: 'warning', text: '心率偏离正常范围' };
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const v = values.blood_oxygen ?? 0;
|
||||
if (v >= 95) return { level: 'normal', text: '血氧正常' };
|
||||
if (v >= 90) return { level: 'warning', text: '血氧偏低' };
|
||||
return { level: 'danger', text: '血氧过低' };
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
const sys = values.systolic ?? 0;
|
||||
const dia = values.diastolic ?? 0;
|
||||
if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' };
|
||||
if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' };
|
||||
return { level: 'warning', text: '血压偏高' };
|
||||
}
|
||||
case 'temperature': {
|
||||
const v = values.temperature ?? 0;
|
||||
if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' };
|
||||
if (v > 38.0) return { level: 'danger', text: '发热' };
|
||||
return { level: 'warning', text: '体温偏离正常' };
|
||||
}
|
||||
case 'pressure': {
|
||||
const v = values.pressure ?? 0;
|
||||
if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' };
|
||||
if (v > 60) return { level: 'danger', text: '压力过高' };
|
||||
return { level: 'warning', text: '压力偏高' };
|
||||
}
|
||||
default:
|
||||
return { level: 'normal', text: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化显示值 */
|
||||
function formatValue(type: string, values: Record<string, number>): string {
|
||||
if (type === 'blood_pressure') {
|
||||
return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`;
|
||||
}
|
||||
const v = Object.values(values)[0];
|
||||
return v !== undefined ? String(v) : '--';
|
||||
}
|
||||
|
||||
export default function VeepooMeasure() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const patient = useAuthStore((s) => s.currentPatient);
|
||||
const navigatedRef = useRef(false);
|
||||
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
|
||||
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
|
||||
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||
|
||||
// 从 URL 或 store 获取 patientId
|
||||
const patientId = patient?.id || router.params.patientId || '';
|
||||
|
||||
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
|
||||
if (!navigatedRef.current) {
|
||||
navigatedRef.current = true;
|
||||
// 延迟到下一个微任务,确保页面渲染完成后再跳转
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({
|
||||
url: `/pkg-veepoo/index?patientId=${patientId}`,
|
||||
events: {
|
||||
measureResult: (data: NativeMeasureResult) => {
|
||||
setResults((prev) => ({ ...prev, [data.type]: data }));
|
||||
},
|
||||
measureComplete: (data: { results: Record<string, NativeMeasureResult>; count: number }) => {
|
||||
if (data.results) setResults(data.results);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 页面恢复时读取原生页面返回的测量结果 + 睡眠数据
|
||||
useDidShow(() => {
|
||||
try {
|
||||
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, NativeMeasureResult>;
|
||||
setResults(parsed);
|
||||
Taro.removeStorageSync('hms:veepoo_measure_results');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results');
|
||||
if (rawSleep) {
|
||||
const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[];
|
||||
if (parsedSleep.length > 0) {
|
||||
setSleepData(parsedSleep);
|
||||
}
|
||||
Taro.removeStorageSync('hms:veepoo_sleep_results');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
// 修复:添加明确的错误提示,不再静默退出
|
||||
if (!patientId) {
|
||||
console.warn('[veepoo-measure] 上传失败:未获取到患者 ID');
|
||||
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const allResults = Object.values(results);
|
||||
const hasMeasureData = allResults.length > 0;
|
||||
const hasSleep = sleepData.length > 0;
|
||||
|
||||
if (!hasMeasureData && !hasSleep) {
|
||||
console.warn('[veepoo-measure] 上传失败:无数据');
|
||||
Taro.showToast({ title: '暂无测量数据', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadStatus('uploading');
|
||||
try {
|
||||
const allReadings: NormalizedReading[] = [];
|
||||
|
||||
// 测量结果
|
||||
if (hasMeasureData) {
|
||||
console.log('[veepoo-measure] 上传测量数据', allResults.length, '项');
|
||||
allReadings.push(...allResults.map((r) => ({
|
||||
device_type: r.type as NormalizedReading['device_type'],
|
||||
values: r.values,
|
||||
measured_at: new Date(r.measuredAt).toISOString(),
|
||||
})));
|
||||
}
|
||||
|
||||
// 睡眠数据
|
||||
if (hasSleep) {
|
||||
const now = new Date();
|
||||
console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天');
|
||||
allReadings.push(...sleepData.map((s) => {
|
||||
const baseDate = new Date(now.getTime() - s.day * 86400000);
|
||||
return {
|
||||
device_type: 'sleep' as const,
|
||||
values: {
|
||||
deep_sleep_minutes: s.deepSleepMinutes,
|
||||
light_sleep_minutes: s.lightSleepMinutes,
|
||||
total_sleep_minutes: s.totalSleepMinutes,
|
||||
quality_score: s.qualityScore,
|
||||
},
|
||||
measured_at: baseDate.toISOString(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings);
|
||||
setUploadStatus('success');
|
||||
Taro.showToast({ title: '数据已上传', icon: 'success' });
|
||||
} catch (err) {
|
||||
console.error('[veepoo-measure] 上传失败:', err);
|
||||
setUploadStatus('error');
|
||||
Taro.showToast({ title: '上传失败,请重试', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = Object.keys(results).length > 0;
|
||||
const measuredCount = Object.keys(results).length;
|
||||
const measuredAt = hasResults
|
||||
? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<PageShell padding="none" className={`vm-page ${modeClass}`}>
|
||||
{hasResults ? (
|
||||
<View className="vm-upload">
|
||||
{/* 页面标题 */}
|
||||
<View className="vm-upload__header">
|
||||
<Text className="vm-upload__title">测量结果</Text>
|
||||
<Text className="vm-upload__subtitle">Veepoo M2 · 刚刚完成测量</Text>
|
||||
</View>
|
||||
|
||||
{/* 结果卡片网格 */}
|
||||
<View className="vm-results-grid">
|
||||
{METRIC_CONFIG.map((metric) => {
|
||||
const result = results[metric.type];
|
||||
if (result) {
|
||||
const assessment = assessHealth(metric.type, result.values);
|
||||
return (
|
||||
<View
|
||||
key={metric.type}
|
||||
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
|
||||
>
|
||||
<View className="vm-result-card__badge" style={{ background: metric.color }} />
|
||||
<Text className="vm-result-card__label">{metric.label}</Text>
|
||||
<View className="vm-result-card__row">
|
||||
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
|
||||
<Text className="vm-result-card__unit">{metric.unit}</Text>
|
||||
</View>
|
||||
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
|
||||
<Text>● {assessment.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// 未测量占位
|
||||
return (
|
||||
<View
|
||||
key={metric.type}
|
||||
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
|
||||
>
|
||||
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
|
||||
<Text className="vm-result-card__label">{metric.label}</Text>
|
||||
<Text className="vm-result-card__placeholder">未测量</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 睡眠数据卡片 */}
|
||||
{sleepData.length > 0 && (
|
||||
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
|
||||
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
|
||||
<Text className="vm-result-card__label">睡眠数据({sleepData.length} 天)</Text>
|
||||
{sleepData.map((sleep, idx) => {
|
||||
const hours = Math.floor(sleep.totalSleepMinutes / 60);
|
||||
const mins = sleep.totalSleepMinutes % 60;
|
||||
const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚';
|
||||
return (
|
||||
<View key={idx} className="vm-sleep-row">
|
||||
<Text className="vm-sleep-row__day">{dayLabel}</Text>
|
||||
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
|
||||
<View className="vm-sleep-row__quality">
|
||||
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="vm-result-card__tag vm-result-card__tag--normal">
|
||||
<Text>● 自动同步</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 底部上传播区 */}
|
||||
<View className="vm-upload-footer">
|
||||
<Text className="vm-upload-footer__hint">测量数据将上传至您的健康档案</Text>
|
||||
<View className="vm-upload-footer__btn">
|
||||
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
|
||||
{uploadStatus === 'uploading'
|
||||
? '上传中...'
|
||||
: uploadStatus === 'success'
|
||||
? '✓ 已上传'
|
||||
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''})`}
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
{measuredAt && <Text className="vm-upload-footer__time">测量时间:{measuredAt}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="vm-connect">
|
||||
<View className="vm-connect__anim">
|
||||
<View className="vm-connect__ring" />
|
||||
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
|
||||
</View>
|
||||
<Text className="vm-connect__title">M2 手环健康测量</Text>
|
||||
<Text className="vm-connect__hint">即将跳转到设备测量页面...</Text>
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,9 @@ export interface PatientSummary {
|
||||
}
|
||||
|
||||
/** 获取患者摘要列表(字段最小化,替代 getPatients) */
|
||||
export async function getPatientSummaries() {
|
||||
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary');
|
||||
export async function getPatientSummaries(userId?: string) {
|
||||
const params: Record<string, string> = {};
|
||||
if (userId) params.user_id = userId;
|
||||
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary', { params });
|
||||
return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
|
||||
}
|
||||
|
||||
305
apps/miniprogram/src/services/ble/VeepooBridge.ts
Normal file
305
apps/miniprogram/src/services/ble/VeepooBridge.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Veepoo SDK 桥接模块
|
||||
*
|
||||
* 调用顺序(基于 SDK Demo 验证):
|
||||
* 1. startScan() — 初始化蓝牙 + 扫描
|
||||
* 2. stopScan() — 找到设备后停止扫描
|
||||
* 3. connectDevice(deviceObj) — 传入完整设备对象(非 deviceId 字符串)
|
||||
* 4. registerDataListener() — 连接成功后注册数据监听
|
||||
* 5. authenticate() — 延迟 500ms 后调用秘钥认证
|
||||
* 6. 认证结果通过数据监听回调 type=1 返回
|
||||
*/
|
||||
|
||||
// @ts-ignore — SDK 类型声明为 any
|
||||
import { veepooBle, veepooFeature, veepooLogger } from './veepoo-sdk';
|
||||
|
||||
// ── SDK 事件类型常量 ──
|
||||
|
||||
/** 秘钥认证结果 */
|
||||
export const SDK_EVENT_AUTH = 1;
|
||||
/** 日常数据 */
|
||||
export const SDK_EVENT_DAILY = 5;
|
||||
/** 体温检测 */
|
||||
export const SDK_EVENT_TEMPERATURE = 6;
|
||||
/** 血压 */
|
||||
export const SDK_EVENT_BLOOD_PRESSURE = 18;
|
||||
/** 血氧手动测量 */
|
||||
export const SDK_EVENT_BLOOD_OXYGEN = 31;
|
||||
/** 心率测量 */
|
||||
export const SDK_EVENT_HEART_RATE = 51;
|
||||
/** 压力测量 */
|
||||
export const SDK_EVENT_PRESSURE = 58;
|
||||
|
||||
/** 设备正忙状态枚举(SDK state 字段) */
|
||||
export const DEVICE_STATE = {
|
||||
IDLE: 0,
|
||||
MEASURING_BP: 1,
|
||||
MEASURING_HR: 2,
|
||||
AUTO_TEST: 3,
|
||||
MEASURING_SPO2: 4,
|
||||
MEASURING_FATIGUE: 5,
|
||||
NOT_WORN: 6,
|
||||
CHARGING: 7,
|
||||
LOW_BATTERY: 8,
|
||||
BUSY: 9,
|
||||
} as const;
|
||||
|
||||
/** 连接回调中 connection 字段为 true 表示连接成功 */
|
||||
export interface VeepooConnectionResult {
|
||||
connection?: boolean;
|
||||
errno?: number;
|
||||
errCode?: number;
|
||||
errMsg?: string;
|
||||
}
|
||||
|
||||
/** SDK 事件回调数据(统一格式) */
|
||||
export interface SdkEventData {
|
||||
name: string;
|
||||
type: number;
|
||||
content: Record<string, unknown>;
|
||||
Progress?: number;
|
||||
state?: number;
|
||||
control?: number;
|
||||
ack?: number;
|
||||
}
|
||||
|
||||
// ── 蓝牙模块 ──
|
||||
|
||||
/** 初始化蓝牙 + 开始扫描 */
|
||||
export function startScan(onDeviceFound: (device: unknown) => void): void {
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(
|
||||
(res: unknown) => {
|
||||
const device = Array.isArray(res) ? res[0] : res;
|
||||
if (device) onDeviceFound(device);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 停止扫描 */
|
||||
export function stopScan(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/** 连接设备 — 传入完整设备对象 */
|
||||
export function connectDevice(device: unknown): Promise<VeepooConnectionResult> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(
|
||||
device,
|
||||
(res: VeepooConnectionResult) => resolve(res),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册数据监听(必须在连接成功后调用) */
|
||||
export function registerDataListener(callback: (data: SdkEventData) => void): void {
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(callback);
|
||||
}
|
||||
|
||||
/** 监听蓝牙连接状态变化 */
|
||||
export function registerConnectionListener(callback: (res: { deviceId: string; connected: boolean }) => void): void {
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(callback);
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
export function disconnect(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ── 功能模块:认证 ──
|
||||
|
||||
/** 秘钥认证(无参数无回调,结果通过数据监听 type=1 返回) */
|
||||
export function authenticate(): void {
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
}
|
||||
|
||||
// ── 功能模块:测量指令 ──
|
||||
|
||||
/** 心率测量开关(true=开启,false=关闭) */
|
||||
export function setHeartRateMeasure(on: boolean): void {
|
||||
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: on });
|
||||
}
|
||||
|
||||
/** 血氧测量开关('start'=开启,'stop'=关闭) */
|
||||
export function setBloodOxygenMeasure(action: 'start' | 'stop'): void {
|
||||
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: action });
|
||||
}
|
||||
|
||||
/** 血压测量开关('start'=开启,'stop'=关闭) */
|
||||
export function setBloodPressureMeasure(action: 'start' | 'stop'): void {
|
||||
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: action });
|
||||
}
|
||||
|
||||
/** 体温测量(单次触发) */
|
||||
export function startTemperatureMeasure(): void {
|
||||
veepooFeature.veepooSendTemperatureMeasurementSwitchManager();
|
||||
}
|
||||
|
||||
/** 压力测量开关(true=开启,false=关闭) */
|
||||
export function setPressureMeasure(on: boolean): void {
|
||||
veepooFeature.veepooSendPressureTestManager({ switch: on });
|
||||
}
|
||||
|
||||
// ── 功能模块:日常数据 ──
|
||||
|
||||
/** 读取日常数据(day: 0=今天, 1=昨天, 2=前天;package: 开始包序号,默认1) */
|
||||
export function readDailyData(day: number, pkg: number = 1): void {
|
||||
veepooFeature.veepooSendReadDailyDataManager({ day, package: pkg });
|
||||
}
|
||||
|
||||
// ── 功能模块:精准睡眠数据 ──
|
||||
|
||||
/** 精准睡眠事件类型 */
|
||||
export const SDK_EVENT_SLEEP = 4;
|
||||
|
||||
/** 精准睡眠数据(SDK 回调 type=4) */
|
||||
export interface SleepData {
|
||||
/** 入睡时间(时间戳字符串) */
|
||||
fallAsleepTime: string;
|
||||
/** 退出睡眠时间(时间戳字符串) */
|
||||
exitSleepTime: string;
|
||||
/** 起夜得分 */
|
||||
nightScore: number;
|
||||
/** 深睡得分 */
|
||||
deepSleepScore: number;
|
||||
/** 睡眠效率得分 */
|
||||
sleepEfficiencyScore: number;
|
||||
/** 入睡效率得分 */
|
||||
fallAsleepEfficiencyScore: number;
|
||||
/** 睡眠时长得分 */
|
||||
sleepTimeScore: number;
|
||||
/** 睡眠质量(1-5 星) */
|
||||
sleepQuality: number;
|
||||
/** 深睡时长(分钟) */
|
||||
deepSleepTime: number;
|
||||
/** 浅睡时长(分钟) */
|
||||
lightSleepTime: number;
|
||||
/** 其他睡眠时长(分钟) */
|
||||
otherSleepTime: number;
|
||||
/** 睡眠总时长(分钟) */
|
||||
sleepTotalTime: number;
|
||||
/** 首次深睡眠时长(分钟) */
|
||||
firstDeepSleepTime: number;
|
||||
/** 起夜总时长(分钟) */
|
||||
nightTotalTime: number;
|
||||
/** 起夜到深睡均值 */
|
||||
nightDeepSleepMeanValue: number;
|
||||
/** 失眠得分 */
|
||||
insomniaScore: number;
|
||||
/** 失眠次数 */
|
||||
insomniaCount: number;
|
||||
/** 睡眠曲线字符串(0=深睡, 1=浅睡, 2=REM, 3=失眠, 4=苏醒) */
|
||||
sleepCurve: string;
|
||||
}
|
||||
|
||||
/** 读取精准睡眠数据(day: 0=今天, 1=昨天, 2=前天) */
|
||||
export function readPreciseSleepData(day: number): void {
|
||||
veepooFeature.veepooSendReadPreciseSleepManager({ day });
|
||||
}
|
||||
|
||||
// ── 功能模块:自动测量(B3) ──
|
||||
|
||||
/** 自动测量事件类型 */
|
||||
export const SDK_EVENT_AUTO_TEST = 54;
|
||||
|
||||
/** B3 自动测量功能类型枚举 */
|
||||
export const AUTO_TEST_FUN_TYPES = {
|
||||
PULSE_RATE: 0, // 脉率
|
||||
BLOOD_PRESSURE: 1, // 血压
|
||||
BLOOD_GLUCOSE: 2, // 血糖
|
||||
PRESSURE: 3, // 压力
|
||||
BLOOD_OXYGEN: 4, // 血氧
|
||||
TEMPERATURE: 5, // 体温
|
||||
LORENTZ_SCATTER: 6, // 洛伦兹散点图
|
||||
HRV: 7, // HRV
|
||||
BLOOD_COMPONENT: 8, // 血液成分
|
||||
} as const;
|
||||
|
||||
export type AutoTestFunType = (typeof AUTO_TEST_FUN_TYPES)[keyof typeof AUTO_TEST_FUN_TYPES];
|
||||
|
||||
/** B3 自动测量配置项 */
|
||||
export interface AutoTestConfig {
|
||||
/** 协议类型(不可修改) */
|
||||
protocolType: number;
|
||||
/** 功能类型 0-8(可修改) */
|
||||
funTypeContent: AutoTestFunType;
|
||||
/** 开关:0=关闭, 1=开启 */
|
||||
funSwitch: number;
|
||||
/** 最小步进(分钟) */
|
||||
stepUnit: number;
|
||||
/** 是否支持时间段修改 */
|
||||
timeSlotModify: number;
|
||||
/** 是否支持时间间隔修改 */
|
||||
timeIntervalModify: number;
|
||||
/** 支持的测试时间段 */
|
||||
supportTimeSlot: { startTime: string; stopTime: string };
|
||||
/** 测量间隔(分钟,按 stepUnit 递增) */
|
||||
measInterval: number;
|
||||
/** 当前测试时间段 */
|
||||
currentTimeSlot: { startTime: string; stopTime: string };
|
||||
}
|
||||
|
||||
/** 读取自动测量功能配置 */
|
||||
export function readAutoTestConfig(): void {
|
||||
veepooFeature.veepooSendReadB3AutoTestFeatureDataManager();
|
||||
}
|
||||
|
||||
/** 设置自动测量功能 */
|
||||
export function setAutoTestConfig(config: AutoTestConfig): void {
|
||||
veepooFeature.veepooSendSetupB3AutoTestFeatureDataManager({
|
||||
p_protocol_type: config.protocolType,
|
||||
p_fun_type_content: config.funTypeContent,
|
||||
p_fun_switch: config.funSwitch,
|
||||
p_step_unit: config.stepUnit,
|
||||
p_time_slot_modify: config.timeSlotModify,
|
||||
p_time_interval_modify: config.timeIntervalModify,
|
||||
p_support_time_slot: config.supportTimeSlot,
|
||||
p_meas_inv: config.measInterval,
|
||||
p_cur_time_slot: config.currentTimeSlot,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 功能模块:开关设置 ──
|
||||
|
||||
/** 自动心率监测开关 */
|
||||
export function setAutoHeartRate(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticHRTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 自动血压监测开关 */
|
||||
export function setAutoBloodPressure(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticBPTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 体温自动监测 */
|
||||
export function setAutoTemperature(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticTemperatureTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取体温自动监测数据 */
|
||||
export function readAutoTemperatureData(): void {
|
||||
veepooFeature.veepooReadAutoTemperatureMeasurementDataManager({ day: 0 });
|
||||
}
|
||||
|
||||
// ── 功能模块:设备信息 ──
|
||||
|
||||
/** 读取设备电量 */
|
||||
export function readBatteryLevel(): void {
|
||||
veepooFeature.veepooReadElectricQuantityManager();
|
||||
}
|
||||
|
||||
// ── 日志模块 ──
|
||||
|
||||
/** 设置日志级别(0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NONE) */
|
||||
export function setLogLevel(level: number): void {
|
||||
veepooLogger.setLevel(level);
|
||||
}
|
||||
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传
|
||||
*
|
||||
* SDK 日常数据格式(type=5):
|
||||
* - 包含计步、心率、血压、血氧、睡眠、压力、体温等
|
||||
* - Progress 字段 1-100% 表示读取进度
|
||||
* - 每次回调可能包含一包数据
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import { readDailyData } from '../VeepooBridge';
|
||||
import type { SdkEventData } from '../VeepooBridge';
|
||||
import type { NormalizedReading } from '../types';
|
||||
import type { SleepReading } from './types';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
|
||||
const CHECKPOINT_KEY = 'veepoo_history_checkpoint';
|
||||
const UPLOAD_BATCH_SIZE = 20;
|
||||
|
||||
interface Checkpoint {
|
||||
lastProgress: number;
|
||||
packagesRead: number;
|
||||
deviceId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error';
|
||||
|
||||
export class VeepooHistoryReader {
|
||||
private phase: HistoryReadPhase = 'idle';
|
||||
private progress = 0;
|
||||
private packagesRead = 0;
|
||||
private buffer: NormalizedReading[] = [];
|
||||
private day = 0;
|
||||
private patientId = '';
|
||||
private deviceId = '';
|
||||
private onProgress?: (progress: number, phase: HistoryReadPhase) => void;
|
||||
private uploadedCount = 0;
|
||||
|
||||
setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void {
|
||||
this.onProgress = cbs.onProgress;
|
||||
}
|
||||
|
||||
/** 开始读取3天数据 */
|
||||
async startRead(patientId: string, deviceId: string): Promise<number> {
|
||||
this.patientId = patientId;
|
||||
this.deviceId = deviceId;
|
||||
this.buffer = [];
|
||||
this.uploadedCount = 0;
|
||||
this.phase = 'reading';
|
||||
|
||||
// 依次读取 3 天数据
|
||||
for (let day = 0; day < 3; day++) {
|
||||
this.day = day;
|
||||
this.progress = 0;
|
||||
this.onProgress?.(0, 'reading');
|
||||
|
||||
await this.readDay(day);
|
||||
|
||||
// 刷新剩余 buffer
|
||||
if (this.buffer.length > 0) {
|
||||
await this.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
this.phase = 'done';
|
||||
this.onProgress?.(100, 'done');
|
||||
this.clearCheckpoint();
|
||||
|
||||
return this.uploadedCount;
|
||||
}
|
||||
|
||||
/** 读取单天数据 */
|
||||
private readDay(day: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// 发送读取指令
|
||||
readDailyData(day, 1);
|
||||
|
||||
// 进度通过 handleDailyEvent 更新
|
||||
// Progress=100 时 resolve
|
||||
this.dayResolve = resolve;
|
||||
|
||||
// 超时保护:30s
|
||||
this.dayTimeout = setTimeout(() => {
|
||||
this.dayResolve = null;
|
||||
resolve();
|
||||
}, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
private dayResolve: (() => void) | null = null;
|
||||
private dayTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** 处理 SDK 日常数据回调 */
|
||||
handleDailyEvent(data: SdkEventData): void {
|
||||
if (this.phase !== 'reading') return;
|
||||
|
||||
const progress = (data.Progress ?? 0) as number;
|
||||
this.progress = progress;
|
||||
this.onProgress?.(progress, 'reading');
|
||||
|
||||
// 解析数据
|
||||
const readings = this.parseDailyData(data);
|
||||
if (readings.length > 0) {
|
||||
this.buffer.push(...readings);
|
||||
this.packagesRead++;
|
||||
}
|
||||
|
||||
// 达到批量大小就上传
|
||||
if (this.buffer.length >= UPLOAD_BATCH_SIZE) {
|
||||
this.flushBuffer();
|
||||
}
|
||||
|
||||
// 进度 100% 表示当天数据读取完成
|
||||
if (progress >= 100) {
|
||||
if (this.dayTimeout) clearTimeout(this.dayTimeout);
|
||||
this.dayTimeout = null;
|
||||
const resolve = this.dayResolve;
|
||||
this.dayResolve = null;
|
||||
resolve?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 SDK 日常数据为 NormalizedReading */
|
||||
private parseDailyData(data: SdkEventData): NormalizedReading[] {
|
||||
const content = data.content ?? {};
|
||||
const readings: NormalizedReading[] = [];
|
||||
const now = new Date();
|
||||
// 偏移到对应天
|
||||
const baseDate = new Date(now.getTime() - this.day * 86400000);
|
||||
const timestamp = baseDate.toISOString();
|
||||
|
||||
// 心率
|
||||
const hr = content.heartReat ?? content.heartRate;
|
||||
if (typeof hr === 'number' && hr >= 30 && hr <= 250) {
|
||||
readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 血氧
|
||||
const bo = content.bloodOxygen;
|
||||
if (typeof bo === 'number' && bo >= 70 && bo <= 100) {
|
||||
readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 血压
|
||||
const bph = content.bloodPressureHigh;
|
||||
const bpl = content.bloodPressureLow;
|
||||
if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) {
|
||||
readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 体温
|
||||
const temp = content.bodyTemperature;
|
||||
if (typeof temp === 'number' && temp > 30 && temp < 45) {
|
||||
readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 压力
|
||||
const pressure = content.pressure;
|
||||
if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) {
|
||||
readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 步数
|
||||
const steps = content.stepCount ?? content.steps;
|
||||
if (typeof steps === 'number' && steps >= 0) {
|
||||
readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
|
||||
/** 上传 buffer 中的数据 */
|
||||
private async flushBuffer(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
|
||||
const batch = this.buffer.splice(0, this.buffer.length);
|
||||
this.phase = 'uploading';
|
||||
this.onProgress?.(this.progress, 'uploading');
|
||||
|
||||
try {
|
||||
await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch);
|
||||
this.uploadedCount += batch.length;
|
||||
this.saveCheckpoint();
|
||||
} catch {
|
||||
// 上传失败,放回 buffer
|
||||
this.buffer.unshift(...batch);
|
||||
}
|
||||
|
||||
this.phase = 'reading';
|
||||
}
|
||||
|
||||
private saveCheckpoint(): void {
|
||||
try {
|
||||
const checkpoint: Checkpoint = {
|
||||
lastProgress: this.progress,
|
||||
packagesRead: this.packagesRead,
|
||||
deviceId: this.deviceId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private clearCheckpoint(): void {
|
||||
try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
getPhase(): HistoryReadPhase { return this.phase; }
|
||||
getProgress(): number { return this.progress; }
|
||||
getUploadedCount(): number { return this.uploadedCount; }
|
||||
|
||||
// ── 睡眠数据上传 ──
|
||||
|
||||
/** 将睡眠数据转换为 NormalizedReading 并上传 */
|
||||
async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise<number> {
|
||||
if (sleepData.length === 0) return 0;
|
||||
|
||||
const now = new Date();
|
||||
const readings: NormalizedReading[] = sleepData.map((sleep) => {
|
||||
// 根据天数偏移计算日期
|
||||
const baseDate = new Date(now.getTime() - sleep.day * 86400000);
|
||||
return {
|
||||
device_type: 'sleep',
|
||||
values: {
|
||||
deep_sleep_minutes: sleep.deepSleepMinutes,
|
||||
light_sleep_minutes: sleep.lightSleepMinutes,
|
||||
total_sleep_minutes: sleep.totalSleepMinutes,
|
||||
quality_score: sleep.qualityScore,
|
||||
},
|
||||
measured_at: baseDate.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadReadings(patientId, deviceId, 'Veepoo M2', readings);
|
||||
this.uploadedCount += readings.length;
|
||||
console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条');
|
||||
return readings.length;
|
||||
} catch (err) {
|
||||
console.error('[veepoo-history] 睡眠数据上传失败:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
588
apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts
Normal file
588
apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装
|
||||
*
|
||||
* 职责:
|
||||
* 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪
|
||||
* 2. SDK 事件路由:registerDataListener 按 type 分发
|
||||
* 3. 测量 Promise 化:startMeasure(type) → Promise<MeasureResult>
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
startScan,
|
||||
stopScan,
|
||||
connectDevice,
|
||||
registerDataListener,
|
||||
registerConnectionListener,
|
||||
authenticate,
|
||||
disconnect as veepooDisconnect,
|
||||
setHeartRateMeasure,
|
||||
setBloodOxygenMeasure,
|
||||
setBloodPressureMeasure,
|
||||
startTemperatureMeasure,
|
||||
setPressureMeasure,
|
||||
readBatteryLevel,
|
||||
readPreciseSleepData,
|
||||
readAutoTestConfig,
|
||||
setAutoHeartRate,
|
||||
setAutoBloodPressure,
|
||||
setAutoTemperature,
|
||||
setLogLevel,
|
||||
SDK_EVENT_AUTH,
|
||||
SDK_EVENT_HEART_RATE,
|
||||
SDK_EVENT_BLOOD_OXYGEN,
|
||||
SDK_EVENT_BLOOD_PRESSURE,
|
||||
SDK_EVENT_TEMPERATURE,
|
||||
SDK_EVENT_PRESSURE,
|
||||
SDK_EVENT_DAILY,
|
||||
SDK_EVENT_SLEEP,
|
||||
SDK_EVENT_AUTO_TEST,
|
||||
DEVICE_STATE,
|
||||
} from '../VeepooBridge';
|
||||
import type { SdkEventData } from '../VeepooBridge';
|
||||
import type { MeasureType, MeasureResult, SleepReading } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 8_000;
|
||||
const AUTH_POLL_INTERVAL = 500;
|
||||
const MEASURE_SETTLE_DELAY = 1_500;
|
||||
|
||||
/** pending 测量的 resolve/reject 句柄 */
|
||||
interface PendingMeasure {
|
||||
type: MeasureType;
|
||||
resolve: (result: MeasureResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
lastValue: number | null;
|
||||
lastValues: Record<string, number>;
|
||||
settleTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/** SDK type 到 MeasureType 的映射 */
|
||||
const SDK_TYPE_TO_MEASURE: Record<number, MeasureType> = {
|
||||
[SDK_EVENT_HEART_RATE]: 'heart_rate',
|
||||
[SDK_EVENT_BLOOD_OXYGEN]: 'blood_oxygen',
|
||||
[SDK_EVENT_BLOOD_PRESSURE]: 'blood_pressure',
|
||||
[SDK_EVENT_TEMPERATURE]: 'temperature',
|
||||
[SDK_EVENT_PRESSURE]: 'pressure',
|
||||
};
|
||||
|
||||
export type ConnectionChangeCallback = (connected: boolean, deviceId: string) => void;
|
||||
export type AuthResultCallback = (success: boolean) => void;
|
||||
export type MeasureEventCallback = (type: MeasureType, data: Record<string, unknown>) => void;
|
||||
export type DailyDataCallback = (data: SdkEventData) => void;
|
||||
export type SleepDataCallback = (day: number, sleep: SleepReading) => void;
|
||||
|
||||
export class VeepooPipeline {
|
||||
private pending: PendingMeasure | null = null;
|
||||
private isConnected = false;
|
||||
private deviceId = '';
|
||||
|
||||
/** 睡眠数据读取 Promise resolve 队列 */
|
||||
private sleepResolvers: Map<number, (sleep: SleepReading | null) => void> = new Map();
|
||||
private sleepTimeouts: Map<number, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
private onConnectionChange?: ConnectionChangeCallback;
|
||||
private onAuthResult?: AuthResultCallback;
|
||||
private onMeasureEvent?: MeasureEventCallback;
|
||||
private onDailyData?: DailyDataCallback;
|
||||
private onSleepData?: SleepDataCallback;
|
||||
|
||||
/** 注册回调 */
|
||||
setCallbacks(cbs: {
|
||||
onConnectionChange?: ConnectionChangeCallback;
|
||||
onAuthResult?: AuthResultCallback;
|
||||
onMeasureEvent?: MeasureEventCallback;
|
||||
onDailyData?: DailyDataCallback;
|
||||
onSleepData?: SleepDataCallback;
|
||||
}): void {
|
||||
this.onConnectionChange = cbs.onConnectionChange;
|
||||
this.onAuthResult = cbs.onAuthResult;
|
||||
this.onMeasureEvent = cbs.onMeasureEvent;
|
||||
this.onDailyData = cbs.onDailyData;
|
||||
this.onSleepData = cbs.onSleepData;
|
||||
}
|
||||
|
||||
/** 全流程:扫描 → 连接 → 注册监听 → 认证 */
|
||||
async connect(targetName: string, debug = false): Promise<string> {
|
||||
console.log('[veepoo-pipeline] connect() 开始, target:', targetName);
|
||||
if (debug) setLogLevel(0);
|
||||
|
||||
// 1. 扫描
|
||||
console.log('[veepoo-pipeline] Step 1: 扫描...');
|
||||
const device = await this.scanFor(targetName);
|
||||
if (!device) {
|
||||
console.error('[veepoo-pipeline] 扫描未找到设备');
|
||||
throw new Error(`未找到设备 ${targetName}`);
|
||||
}
|
||||
console.log('[veepoo-pipeline] 找到设备:', (device as Record<string, unknown>)?.deviceId);
|
||||
|
||||
// 2. 连接
|
||||
console.log('[veepoo-pipeline] Step 2: 连接...');
|
||||
const connRes = await connectDevice(device);
|
||||
console.log('[veepoo-pipeline] 连接结果:', JSON.stringify(connRes));
|
||||
// SDK 连接成功返回 errno=0 或 connection=true,两种都要兼容
|
||||
const ok = connRes?.connection === true || connRes?.errno === 0 || connRes?.errCode === 0;
|
||||
if (!ok) throw new Error('连接失败');
|
||||
|
||||
const id = (device as Record<string, unknown>).deviceId as string;
|
||||
this.deviceId = id;
|
||||
this.isConnected = true;
|
||||
|
||||
// 3. 注册数据监听(连接成功后)
|
||||
registerDataListener((data) => this.routeEvent(data));
|
||||
registerConnectionListener((res) => {
|
||||
this.isConnected = res.connected;
|
||||
this.onConnectionChange?.(res.connected, res.deviceId);
|
||||
});
|
||||
|
||||
// 4. 认证(延迟 500ms)
|
||||
await delay(500);
|
||||
authenticate();
|
||||
|
||||
// 5. 等待认证结果
|
||||
const authOk = await this.waitForAuth();
|
||||
if (!authOk) throw new Error('设备认证失败,请重新连接');
|
||||
|
||||
// 6. 读取电量
|
||||
readBatteryLevel();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 扫描指定名称的设备 */
|
||||
private scanFor(targetName: string): Promise<unknown | null> {
|
||||
return new Promise((resolve) => {
|
||||
let found: unknown = null;
|
||||
const upper = targetName.toUpperCase();
|
||||
|
||||
startScan((device) => {
|
||||
const d = device as Record<string, unknown>;
|
||||
const name = String(d.localName ?? d.name ?? '').toUpperCase();
|
||||
if (name.includes(upper) && !found) {
|
||||
found = device;
|
||||
stopScan().then(() => resolve(found));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!found) {
|
||||
stopScan().then(() => resolve(null));
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** 等待认证结果(轮询 deviceChipStatus) */
|
||||
private waitForAuth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
|
||||
const poll = () => {
|
||||
try {
|
||||
const status = Taro.getStorageSync('deviceChipStatus');
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification') {
|
||||
this.onAuthResult?.(true);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (Date.now() - start >= AUTH_TIMEOUT) {
|
||||
this.onAuthResult?.(false);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, AUTH_POLL_INTERVAL);
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/** SDK 事件路由 */
|
||||
private routeEvent(data: SdkEventData): void {
|
||||
const eventType = data.type;
|
||||
|
||||
// 认证回调
|
||||
if (eventType === SDK_EVENT_AUTH) {
|
||||
const content = data.content ?? {};
|
||||
const password = content.VPDevicepassword;
|
||||
if (password === 'passTheVerification' || password === 'successfulVerification') {
|
||||
this.onAuthResult?.(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 日常数据
|
||||
if (eventType === SDK_EVENT_DAILY) {
|
||||
this.onDailyData?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 精准睡眠数据
|
||||
if (eventType === SDK_EVENT_SLEEP) {
|
||||
this.handleSleepEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动测量功能回调
|
||||
if (eventType === SDK_EVENT_AUTO_TEST) {
|
||||
console.log('[veepoo-pipeline] 自动测量配置回调:', JSON.stringify(data).substring(0, 300));
|
||||
return;
|
||||
}
|
||||
|
||||
// 测量数据
|
||||
const measureType = SDK_TYPE_TO_MEASURE[eventType];
|
||||
if (!measureType) return;
|
||||
|
||||
this.handleMeasureEvent(measureType, data);
|
||||
this.onMeasureEvent?.(measureType, data.content ?? {});
|
||||
}
|
||||
|
||||
/** 处理测量事件 */
|
||||
private handleMeasureEvent(type: MeasureType, data: SdkEventData): void {
|
||||
if (!this.pending || this.pending.type !== type) return;
|
||||
|
||||
const content = data.content ?? {};
|
||||
|
||||
// 检查设备状态错误
|
||||
const deviceBusy = content.deviceBusy === true;
|
||||
const notWear = content.notWear === true;
|
||||
const state = data.state;
|
||||
const ack = data.ack;
|
||||
|
||||
if (deviceBusy) {
|
||||
this.rejectPending(new Error('设备正忙,请稍后重试'));
|
||||
return;
|
||||
}
|
||||
if (notWear || state === DEVICE_STATE.NOT_WORN) {
|
||||
this.rejectPending(new Error('请将手环佩戴到手腕上'));
|
||||
return;
|
||||
}
|
||||
if (state === DEVICE_STATE.CHARGING) {
|
||||
this.rejectPending(new Error('设备正在充电,请取出后重试'));
|
||||
return;
|
||||
}
|
||||
if (state === DEVICE_STATE.LOW_BATTERY) {
|
||||
this.rejectPending(new Error('设备电量不足,请充电后重试'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 2) {
|
||||
this.rejectPending(new Error('设备电量不足'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 3) {
|
||||
this.rejectPending(new Error('设备正在测量其他数据'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 4) {
|
||||
this.rejectPending(new Error('佩戴检测未通过'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取数值
|
||||
const values = this.extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
// 更新 pending 最新值
|
||||
this.pending.lastValues = values;
|
||||
|
||||
// 对于进度型指标,检查是否完成
|
||||
const progress = data.Progress;
|
||||
if (progress !== undefined && progress >= 100) {
|
||||
this.resolvePending(values);
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于持续测量型/单次型,收到第一个有效值后延迟 settle
|
||||
if (this.pending.settleTimer === null) {
|
||||
this.pending.settleTimer = setTimeout(() => {
|
||||
if (this.pending && this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
|
||||
this.resolvePending(this.pending.lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 SDK content 提取标准化数值 */
|
||||
private extractValues(type: MeasureType, content: Record<string, unknown>): Record<string, number> | null {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const hr = Number(content.heartRate);
|
||||
if (hr >= 30 && hr <= 250) return { heart_rate: hr };
|
||||
return null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const bo = Number(content.bloodOxygen);
|
||||
if (bo >= 70 && bo <= 100) return { blood_oxygen: bo };
|
||||
return null;
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
const high = Number(content.bloodPressureHigh);
|
||||
const low = Number(content.bloodPressureLow);
|
||||
if (high > 0 && low > 0) return { systolic: high, diastolic: low };
|
||||
return null;
|
||||
}
|
||||
case 'temperature': {
|
||||
const temp = Number(content.bodyTemperature);
|
||||
if (temp > 30 && temp < 45) return { temperature: temp };
|
||||
return null;
|
||||
}
|
||||
case 'pressure': {
|
||||
const p = Number(content.pressure);
|
||||
if (p >= 0 && p <= 100) return { pressure: p };
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发起测量 */
|
||||
startMeasure(type: MeasureType): Promise<MeasureResult> {
|
||||
if (this.pending) {
|
||||
throw new Error(`正在测量 ${this.pending.type},请等待完成`);
|
||||
}
|
||||
if (!this.isConnected) {
|
||||
throw new Error('设备未连接');
|
||||
}
|
||||
|
||||
return new Promise<MeasureResult>((resolve, reject) => {
|
||||
const timeout = getMeasureTimeout(type);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.rejectPending(new Error('测量超时,请重试'));
|
||||
}, timeout);
|
||||
|
||||
this.pending = {
|
||||
type,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
lastValue: null,
|
||||
lastValues: {},
|
||||
settleTimer: null,
|
||||
};
|
||||
|
||||
// 发送 SDK 测量指令
|
||||
this.sendMeasureCommand(type);
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消当前测量 */
|
||||
cancelMeasure(): void {
|
||||
if (!this.pending) return;
|
||||
this.stopMeasureCommand(this.pending.type);
|
||||
if (this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
|
||||
this.resolvePending(this.pending.lastValues);
|
||||
} else {
|
||||
this.rejectPending(new Error('测量已取消'));
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送 SDK 测量指令 */
|
||||
private sendMeasureCommand(type: MeasureType): void {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
setHeartRateMeasure(true);
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
setBloodOxygenMeasure('start');
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
setBloodPressureMeasure('start');
|
||||
break;
|
||||
case 'temperature':
|
||||
startTemperatureMeasure();
|
||||
break;
|
||||
case 'pressure':
|
||||
setPressureMeasure(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送 SDK 停止测量指令 */
|
||||
private stopMeasureCommand(type: MeasureType): void {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
setHeartRateMeasure(false);
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
setBloodOxygenMeasure('stop');
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
setBloodPressureMeasure('stop');
|
||||
break;
|
||||
case 'temperature':
|
||||
break; // 体温是单次触发,无法停止
|
||||
case 'pressure':
|
||||
setPressureMeasure(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功 resolve pending 测量 */
|
||||
private resolvePending(values: Record<string, number>): void {
|
||||
if (!this.pending) return;
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
clearTimeout(p.timer);
|
||||
if (p.settleTimer) clearTimeout(p.settleTimer);
|
||||
|
||||
// 停止持续测量型指标的 SDK 指令
|
||||
this.stopMeasureCommand(p.type);
|
||||
|
||||
p.resolve({
|
||||
type: p.type,
|
||||
values,
|
||||
measuredAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 失败 reject pending 测量 */
|
||||
private rejectPending(error: Error): void {
|
||||
if (!this.pending) return;
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
clearTimeout(p.timer);
|
||||
if (p.settleTimer) clearTimeout(p.settleTimer);
|
||||
|
||||
// 停止 SDK 指令
|
||||
this.stopMeasureCommand(p.type);
|
||||
|
||||
p.reject(error);
|
||||
}
|
||||
|
||||
// ── 睡眠数据 ──
|
||||
|
||||
/** 读取单天精准睡眠数据,返回 Promise */
|
||||
readSleepData(day: number): Promise<SleepReading | null> {
|
||||
if (!this.isConnected) {
|
||||
return Promise.reject(new Error('设备未连接'));
|
||||
}
|
||||
|
||||
return new Promise<SleepReading | null>((resolve) => {
|
||||
this.sleepResolvers.set(day, resolve);
|
||||
|
||||
// 超时保护 30s
|
||||
const timer = setTimeout(() => {
|
||||
this.sleepResolvers.delete(day);
|
||||
this.sleepTimeouts.delete(day);
|
||||
resolve(null);
|
||||
}, 30_000);
|
||||
this.sleepTimeouts.set(day, timer);
|
||||
|
||||
// 发送 SDK 读取指令
|
||||
readPreciseSleepData(day);
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取 3 天睡眠数据 */
|
||||
async readAllSleepData(): Promise<SleepReading[]> {
|
||||
const results: SleepReading[] = [];
|
||||
for (let day = 0; day < 3; day++) {
|
||||
const sleep = await this.readSleepData(day);
|
||||
if (sleep) {
|
||||
results.push(sleep);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 处理 SDK 睡眠数据回调(type=4) */
|
||||
private handleSleepEvent(data: SdkEventData): void {
|
||||
const progress = data.Progress ?? 0;
|
||||
const readDay = (data as { readDay?: number }).readDay ?? 0;
|
||||
|
||||
// 进度未达 100% 忽略
|
||||
if (progress < 100) return;
|
||||
|
||||
const content = data.content ?? {};
|
||||
const sleep = this.parseSleepData(readDay, content as Record<string, unknown>);
|
||||
|
||||
// 通知回调
|
||||
if (sleep) {
|
||||
this.onSleepData?.(readDay, sleep);
|
||||
}
|
||||
|
||||
// resolve 等待中的 Promise
|
||||
const resolve = this.sleepResolvers.get(readDay);
|
||||
if (resolve) {
|
||||
const timer = this.sleepTimeouts.get(readDay);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.sleepResolvers.delete(readDay);
|
||||
this.sleepTimeouts.delete(readDay);
|
||||
resolve(sleep);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 SDK content 解析精准睡眠数据 */
|
||||
private parseSleepData(day: number, content: Record<string, unknown>): SleepReading | null {
|
||||
const total = Number(content.sleepTotalTime ?? 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
return {
|
||||
day,
|
||||
deepSleepMinutes: Number(content.deepSleepTime ?? 0),
|
||||
lightSleepMinutes: Number(content.lightSleepTime ?? 0),
|
||||
otherSleepMinutes: Number(content.otherSleepTime ?? 0),
|
||||
totalSleepMinutes: total,
|
||||
qualityScore: Number(content.sleepQuality ?? 0),
|
||||
fallAsleepTime: String(content.fallAsleepTime ?? ''),
|
||||
exitSleepTime: String(content.exitSleepTime ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 自动测量 ──
|
||||
|
||||
/** 开启自动测量(心率 + 血压 + 血氧 + 体温) */
|
||||
enableAutoMeasurement(): void {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
console.log('[veepoo-pipeline] 开启自动测量功能');
|
||||
setAutoHeartRate(true);
|
||||
setAutoBloodPressure(true);
|
||||
setAutoTemperature(true);
|
||||
|
||||
// 读取当前自动测量配置
|
||||
readAutoTestConfig();
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pending) {
|
||||
this.rejectPending(new Error('设备已断开'));
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.deviceId = '';
|
||||
await veepooDisconnect();
|
||||
}
|
||||
|
||||
/** 获取连接状态 */
|
||||
getConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/** 获取设备 ID */
|
||||
getDeviceId(): string {
|
||||
return this.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function getMeasureTimeout(type: MeasureType): number {
|
||||
const timeouts: Record<MeasureType, number> = {
|
||||
heart_rate: 60_000,
|
||||
blood_oxygen: 60_000,
|
||||
blood_pressure: 120_000,
|
||||
temperature: 60_000,
|
||||
pressure: 90_000,
|
||||
};
|
||||
return timeouts[type];
|
||||
}
|
||||
21
apps/miniprogram/src/services/ble/veepoo/index.ts
Normal file
21
apps/miniprogram/src/services/ble/veepoo/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { VeepooPipeline } from './VeepooPipeline';
|
||||
export { VeepooHistoryReader } from './VeepooHistoryReader';
|
||||
export type {
|
||||
ConnectionChangeCallback,
|
||||
AuthResultCallback,
|
||||
MeasureEventCallback,
|
||||
DailyDataCallback,
|
||||
} from './VeepooPipeline';
|
||||
export type {
|
||||
MeasureType,
|
||||
MeasurePhase,
|
||||
MeasureStatus,
|
||||
MeasureResult,
|
||||
MeasureConfig,
|
||||
ConnectionPhase,
|
||||
VeepooDeviceInfo,
|
||||
HistorySyncState,
|
||||
SleepReading,
|
||||
AutoTestSyncState,
|
||||
} from './types';
|
||||
export { MEASURE_TYPES, MEASURE_CONFIG } from './types';
|
||||
152
apps/miniprogram/src/services/ble/veepoo/types.ts
Normal file
152
apps/miniprogram/src/services/ble/veepoo/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/** Veepoo 管线专用类型定义 */
|
||||
|
||||
/** 测量指标类型 */
|
||||
export type MeasureType =
|
||||
| 'heart_rate'
|
||||
| 'blood_oxygen'
|
||||
| 'blood_pressure'
|
||||
| 'temperature'
|
||||
| 'pressure';
|
||||
|
||||
/** 所有支持的测量指标 */
|
||||
export const MEASURE_TYPES: readonly MeasureType[] = [
|
||||
'heart_rate',
|
||||
'blood_oxygen',
|
||||
'blood_pressure',
|
||||
'temperature',
|
||||
'pressure',
|
||||
] as const;
|
||||
|
||||
/** 测量指标配置 */
|
||||
export interface MeasureConfig {
|
||||
label: string;
|
||||
unit: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
/** 正常范围 [min, max] */
|
||||
normalRange: [number, number];
|
||||
/** 测量超时(毫秒) */
|
||||
timeout: number;
|
||||
/** 测量模式 */
|
||||
mode: 'continuous' | 'progress' | 'single';
|
||||
}
|
||||
|
||||
/** 各指标配置表 */
|
||||
export const MEASURE_CONFIG: Record<MeasureType, MeasureConfig> = {
|
||||
heart_rate: {
|
||||
label: '心率',
|
||||
unit: 'bpm',
|
||||
icon: '♥',
|
||||
color: '#EF4444',
|
||||
normalRange: [60, 100],
|
||||
timeout: 60_000,
|
||||
mode: 'continuous',
|
||||
},
|
||||
blood_oxygen: {
|
||||
label: '血氧',
|
||||
unit: '%',
|
||||
icon: 'O₂',
|
||||
color: '#3B82F6',
|
||||
normalRange: [95, 100],
|
||||
timeout: 60_000,
|
||||
mode: 'continuous',
|
||||
},
|
||||
blood_pressure: {
|
||||
label: '血压',
|
||||
unit: 'mmHg',
|
||||
icon: '↕',
|
||||
color: '#8B5CF6',
|
||||
normalRange: [90, 140],
|
||||
timeout: 120_000,
|
||||
mode: 'progress',
|
||||
},
|
||||
temperature: {
|
||||
label: '体温',
|
||||
unit: '°C',
|
||||
icon: 'T',
|
||||
color: '#F59E0B',
|
||||
normalRange: [36.0, 37.3],
|
||||
timeout: 60_000,
|
||||
mode: 'single',
|
||||
},
|
||||
pressure: {
|
||||
label: '压力',
|
||||
unit: '',
|
||||
icon: '~',
|
||||
color: '#6366F1',
|
||||
normalRange: [1, 40],
|
||||
timeout: 90_000,
|
||||
mode: 'progress',
|
||||
},
|
||||
};
|
||||
|
||||
/** 连接阶段 */
|
||||
export type ConnectionPhase =
|
||||
| 'idle'
|
||||
| 'scanning'
|
||||
| 'connecting'
|
||||
| 'authenticating'
|
||||
| 'ready'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
/** 测量阶段 */
|
||||
export type MeasurePhase = 'idle' | 'measuring' | 'success' | 'error';
|
||||
|
||||
/** 单个指标的测量状态 */
|
||||
export interface MeasureStatus {
|
||||
phase: MeasurePhase;
|
||||
progress: number;
|
||||
currentValue: number | null;
|
||||
result: MeasureResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 测量结果 */
|
||||
export interface MeasureResult {
|
||||
type: MeasureType;
|
||||
values: Record<string, number>;
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/** 设备信息 */
|
||||
export interface VeepooDeviceInfo {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
batteryLevel: number | null;
|
||||
}
|
||||
|
||||
/** 历史数据同步状态 */
|
||||
export interface HistorySyncState {
|
||||
phase: 'idle' | 'reading' | 'uploading' | 'done';
|
||||
progress: number;
|
||||
packagesRead: number;
|
||||
lastCheckpoint: number;
|
||||
}
|
||||
|
||||
/** 睡眠数据(从 SDK 精准睡眠解析) */
|
||||
export interface SleepReading {
|
||||
/** 读取天数(0=今天, 1=昨天, 2=前天) */
|
||||
day: number;
|
||||
/** 深睡时长(分钟) */
|
||||
deepSleepMinutes: number;
|
||||
/** 浅睡时长(分钟) */
|
||||
lightSleepMinutes: number;
|
||||
/** 其他睡眠时长(分钟) */
|
||||
otherSleepMinutes: number;
|
||||
/** 睡眠总时长(分钟) */
|
||||
totalSleepMinutes: number;
|
||||
/** 睡眠质量评分(1-5 星) */
|
||||
qualityScore: number;
|
||||
/** 入睡时间(时间戳字符串) */
|
||||
fallAsleepTime: string;
|
||||
/** 退出睡眠时间(时间戳字符串) */
|
||||
exitSleepTime: string;
|
||||
}
|
||||
|
||||
/** 自动测量同步状态 */
|
||||
export interface AutoTestSyncState {
|
||||
phase: 'idle' | 'reading_config' | 'configuring' | 'configured';
|
||||
enabledTypes: string[];
|
||||
intervalMinutes: number;
|
||||
}
|
||||
@@ -148,13 +148,19 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
get().loadPatients();
|
||||
return true;
|
||||
}
|
||||
// 未绑定:存储 openid 供后续绑定流程使用
|
||||
if (!resp.openid) {
|
||||
set({ loading: false });
|
||||
throw new Error('登录失败:服务器未返回用户标识');
|
||||
}
|
||||
secureSet('wechat_openid', resp.openid);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('[auth] 微信登录失败:', err);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
// 不吞掉错误 — 让调用方区分"未绑定"和"真正的错误"
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -243,7 +249,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
loadPatients: async () => {
|
||||
try {
|
||||
const summaries = await authApi.getPatientSummaries();
|
||||
const userId = get().user?.id;
|
||||
const summaries = await authApi.getPatientSummaries(userId);
|
||||
const patients: authApi.PatientInfo[] = summaries.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
|
||||
335
apps/miniprogram/src/stores/veepoo.ts
Normal file
335
apps/miniprogram/src/stores/veepoo.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { create } from 'zustand';
|
||||
import { VeepooPipeline } from '@/services/ble/veepoo/VeepooPipeline';
|
||||
import { VeepooHistoryReader } from '@/services/ble/veepoo/VeepooHistoryReader';
|
||||
import type {
|
||||
MeasureType,
|
||||
MeasureStatus,
|
||||
MeasureResult,
|
||||
ConnectionPhase,
|
||||
VeepooDeviceInfo,
|
||||
HistorySyncState,
|
||||
SleepReading,
|
||||
} from '@/services/ble/veepoo/types';
|
||||
import { MEASURE_TYPES } from '@/services/ble/veepoo/types';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
/** 初始化每个指标的默认状态 */
|
||||
function initialMeasureStates(): Record<MeasureType, MeasureStatus> {
|
||||
const states = {} as Record<MeasureType, MeasureStatus>;
|
||||
for (const t of MEASURE_TYPES) {
|
||||
states[t] = { phase: 'idle', progress: 0, currentValue: null, result: null, error: null };
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
interface VeepooState {
|
||||
// 连接
|
||||
connectionPhase: ConnectionPhase;
|
||||
device: VeepooDeviceInfo | null;
|
||||
error: string | null;
|
||||
|
||||
// 测量
|
||||
activeMeasure: MeasureType | null;
|
||||
measureStates: Record<MeasureType, MeasureStatus>;
|
||||
|
||||
// 历史
|
||||
historySync: HistorySyncState;
|
||||
|
||||
// 睡眠
|
||||
sleepData: SleepReading[];
|
||||
sleepLoading: boolean;
|
||||
|
||||
// Actions
|
||||
connect: (targetName?: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
startMeasure: (type: MeasureType) => Promise<MeasureResult>;
|
||||
cancelMeasure: () => void;
|
||||
syncHistory: (patientId: string) => Promise<void>;
|
||||
readSleepData: () => Promise<SleepReading[]>;
|
||||
enableAutoMeasurement: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
let pipelineInstance: VeepooPipeline | null = null;
|
||||
let historyReaderInstance: VeepooHistoryReader | null = null;
|
||||
|
||||
function getPipeline(): VeepooPipeline {
|
||||
if (!pipelineInstance) {
|
||||
pipelineInstance = new VeepooPipeline();
|
||||
}
|
||||
return pipelineInstance;
|
||||
}
|
||||
|
||||
function getHistoryReader(): VeepooHistoryReader {
|
||||
if (!historyReaderInstance) {
|
||||
historyReaderInstance = new VeepooHistoryReader();
|
||||
}
|
||||
return historyReaderInstance;
|
||||
}
|
||||
|
||||
export const useVeepooStore = create<VeepooState>((set, get) => ({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
|
||||
connect: async (targetName = 'M2') => {
|
||||
console.log('[veepoo-store] connect() 开始, target:', targetName);
|
||||
set({ connectionPhase: 'scanning', error: null });
|
||||
const pipeline = getPipeline();
|
||||
const historyReader = getHistoryReader();
|
||||
|
||||
// 注册全部回调(包含新增的 onSleepData)
|
||||
pipeline.setCallbacks({
|
||||
onConnectionChange: (connected) => {
|
||||
if (!connected) {
|
||||
set({ connectionPhase: 'disconnected', device: null });
|
||||
}
|
||||
},
|
||||
onAuthResult: (success) => {
|
||||
if (success) {
|
||||
set({ connectionPhase: 'ready' });
|
||||
}
|
||||
},
|
||||
onMeasureEvent: (type, data) => {
|
||||
const state = get();
|
||||
if (state.activeMeasure !== type) return;
|
||||
|
||||
const value = extractDisplayValue(type, data);
|
||||
set({
|
||||
measureStates: {
|
||||
...state.measureStates,
|
||||
[type]: {
|
||||
...state.measureStates[type],
|
||||
phase: 'measuring',
|
||||
progress: (data.Progress ?? data.progress ?? 0) as number,
|
||||
currentValue: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
onDailyData: (data) => {
|
||||
// 转发给 HistoryReader 处理
|
||||
historyReader.handleDailyEvent(data);
|
||||
|
||||
const progress = data.Progress ?? 0;
|
||||
set((s) => ({
|
||||
historySync: { ...s.historySync, progress: progress as number },
|
||||
}));
|
||||
},
|
||||
onSleepData: (_day, sleep) => {
|
||||
// 收集睡眠数据到 store
|
||||
set((s) => ({
|
||||
sleepData: [...s.sleepData, sleep],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 注册 HistoryReader 进度回调
|
||||
historyReader.setCallbacks({
|
||||
onProgress: (progress, phase) => {
|
||||
set((s) => ({
|
||||
historySync: {
|
||||
...s.historySync,
|
||||
phase: phase === 'uploading' ? 'uploading' : 'reading',
|
||||
progress,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
set({ connectionPhase: 'connecting' });
|
||||
const deviceId = await pipeline.connect(targetName);
|
||||
set({
|
||||
connectionPhase: 'authenticating',
|
||||
device: { deviceId, name: targetName, batteryLevel: null },
|
||||
});
|
||||
|
||||
// 认证结果由 onAuthResult 回调设置
|
||||
// 等待 ready 状态(最多 10s)
|
||||
await waitForState(() => get().connectionPhase === 'ready', 10_000);
|
||||
|
||||
// 认证通过后:自动同步历史 + 读取睡眠 + 开启自动测量
|
||||
const patient = useAuthStore.getState().currentPatient;
|
||||
const readyState = get().connectionPhase === 'ready';
|
||||
if (patient && readyState) {
|
||||
const deviceIdForReader = get().device?.deviceId ?? 'veepoo_m2';
|
||||
|
||||
// 并行执行三件事:
|
||||
// 1. 同步日常历史数据(后台执行,进度通过回调更新)
|
||||
get().syncHistory(patient.id);
|
||||
|
||||
// 2. 读取睡眠数据 → 完成后自动上传
|
||||
get().readSleepData().then((sleepResults) => {
|
||||
if (sleepResults.length > 0) {
|
||||
historyReader.uploadSleepReadings(patient.id, deviceIdForReader, sleepResults);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 开启自动测量(心率+血压+体温)
|
||||
pipeline.enableAutoMeasurement();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] connect 失败:', err);
|
||||
set({
|
||||
connectionPhase: 'error',
|
||||
error: err instanceof Error ? err.message : '连接失败',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
disconnect: async () => {
|
||||
const pipeline = getPipeline();
|
||||
await pipeline.disconnect();
|
||||
set({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
});
|
||||
},
|
||||
|
||||
startMeasure: async (type: MeasureType) => {
|
||||
const state = get();
|
||||
if (state.activeMeasure) {
|
||||
throw new Error(`正在测量 ${state.activeMeasure},请等待完成`);
|
||||
}
|
||||
if (state.connectionPhase !== 'ready') {
|
||||
throw new Error('设备未就绪');
|
||||
}
|
||||
|
||||
set({
|
||||
activeMeasure: type,
|
||||
measureStates: {
|
||||
...state.measureStates,
|
||||
[type]: { phase: 'measuring', progress: 0, currentValue: null, result: null, error: null },
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = getPipeline();
|
||||
try {
|
||||
const result = await pipeline.startMeasure(type);
|
||||
set((s) => ({
|
||||
activeMeasure: null,
|
||||
measureStates: {
|
||||
...s.measureStates,
|
||||
[type]: { phase: 'success', progress: 100, currentValue: null, result, error: null },
|
||||
},
|
||||
}));
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '测量失败';
|
||||
set((s) => ({
|
||||
activeMeasure: null,
|
||||
measureStates: {
|
||||
...s.measureStates,
|
||||
[type]: { phase: 'error', progress: 0, currentValue: null, result: null, error: msg },
|
||||
},
|
||||
}));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
cancelMeasure: () => {
|
||||
const pipeline = getPipeline();
|
||||
pipeline.cancelMeasure();
|
||||
},
|
||||
|
||||
syncHistory: async (patientId: string) => {
|
||||
const deviceId = get().device?.deviceId ?? 'veepoo_m2';
|
||||
set((s) => ({ historySync: { ...s.historySync, phase: 'reading', progress: 0 } }));
|
||||
|
||||
try {
|
||||
const historyReader = getHistoryReader();
|
||||
const count = await historyReader.startRead(patientId, deviceId);
|
||||
set((s) => ({
|
||||
historySync: { ...s.historySync, phase: 'done', progress: 100, packagesRead: count },
|
||||
}));
|
||||
console.log('[veepoo-store] 历史数据同步完成, 上传:', count, '条');
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] 历史数据同步失败:', err);
|
||||
set((s) => ({ historySync: { ...s.historySync, phase: 'done', progress: 100 } }));
|
||||
}
|
||||
},
|
||||
|
||||
readSleepData: async () => {
|
||||
const pipeline = getPipeline();
|
||||
if (!pipeline.getConnected()) {
|
||||
console.warn('[veepoo-store] 设备未连接,跳过睡眠数据读取');
|
||||
return [];
|
||||
}
|
||||
|
||||
set({ sleepLoading: true, sleepData: [] });
|
||||
try {
|
||||
const sleepResults = await pipeline.readAllSleepData();
|
||||
set({ sleepData: sleepResults, sleepLoading: false });
|
||||
console.log('[veepoo-store] 睡眠数据读取完成:', sleepResults.length, '天');
|
||||
return sleepResults;
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] 睡眠数据读取失败:', err);
|
||||
set({ sleepLoading: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
enableAutoMeasurement: () => {
|
||||
const pipeline = getPipeline();
|
||||
pipeline.enableAutoMeasurement();
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/** 从 SDK 事件 content 提取显示值 */
|
||||
function extractDisplayValue(type: MeasureType, content: Record<string, unknown>): number | null {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const v = Number(content.heartRate);
|
||||
return v >= 30 && v <= 250 ? v : null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const v = Number(content.bloodOxygen);
|
||||
return v >= 70 && v <= 100 ? v : null;
|
||||
}
|
||||
case 'blood_pressure':
|
||||
return Number(content.bloodPressureHigh) || null;
|
||||
case 'temperature':
|
||||
return Number(content.bodyTemperature) || null;
|
||||
case 'pressure':
|
||||
return Number(content.pressure) || null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询等待状态满足条件 */
|
||||
function waitForState(check: () => boolean, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const poll = () => {
|
||||
if (check()) { resolve(); return; }
|
||||
if (Date.now() - start >= timeoutMs) { reject(new Error('等待超时')); return; }
|
||||
setTimeout(poll, 200);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function showToast(options: {
|
||||
const duration = options.duration ?? (mode === 'elder' ? 3000 : 1500);
|
||||
|
||||
if (mode === 'elder') {
|
||||
try { Taro.vibrateShort({ type: 'light' }); } catch { /* 不支持时静默 */ }
|
||||
Taro.vibrateShort({ type: 'light' }).catch(() => {});
|
||||
}
|
||||
|
||||
Taro.showToast({ ...options, duration, icon: options.icon ?? 'none' });
|
||||
|
||||
@@ -2,21 +2,15 @@ import Taro from '@tarojs/taro';
|
||||
|
||||
/** 轻触反馈(按钮点击) */
|
||||
export function hapticLight(): void {
|
||||
try {
|
||||
Taro.vibrateShort({ type: 'light' });
|
||||
} catch { /* 部分设备不支持 */ }
|
||||
Taro.vibrateShort({ type: 'light' }).catch(() => { /* DevTools 不支持 type 参数,真机正常 */ });
|
||||
}
|
||||
|
||||
/** 中等反馈(成功操作) */
|
||||
export function hapticMedium(): void {
|
||||
try {
|
||||
Taro.vibrateShort({ type: 'medium' });
|
||||
} catch { /* ignore */ }
|
||||
Taro.vibrateShort({ type: 'medium' }).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
/** 重度反馈(错误/警告) */
|
||||
export function hapticHeavy(): void {
|
||||
try {
|
||||
Taro.vibrateShort({ type: 'heavy' });
|
||||
} catch { /* ignore */ }
|
||||
Taro.vibrateShort({ type: 'heavy' }).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
||||
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
||||
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
|
||||
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
|
||||
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
|
||||
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
|
||||
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||
@@ -330,7 +330,7 @@ export default function App() {
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
<Route path="/health/ai-config" element={<AiConfigPage />} />
|
||||
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
|
||||
<Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
|
||||
<Route path="/ai/chat" element={<AiChatPage />} />
|
||||
<Route path="/health/alerts" element={<AlertList />} />
|
||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import client from '../client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface KnowledgeReference {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
source_name: string;
|
||||
content_summary: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeGuide {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
content: string;
|
||||
category: string | null;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateReferenceReq {
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
source_name: string;
|
||||
content_summary: string;
|
||||
tags?: Record<string, unknown>;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateReferenceReq {
|
||||
title?: string;
|
||||
analysis_type?: string;
|
||||
source_name?: string;
|
||||
content_summary?: string;
|
||||
tags?: Record<string, unknown>;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateGuideReq {
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateGuideReq {
|
||||
title?: string;
|
||||
analysis_type?: string;
|
||||
content?: string;
|
||||
category?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
// === API ===
|
||||
|
||||
export const knowledgeApi = {
|
||||
// References
|
||||
listReferences: async (params?: { analysis_type?: string }) => {
|
||||
const resp = await client.get('/ai/knowledge/references', { params });
|
||||
return resp.data.data as { data: KnowledgeReference[]; total: number };
|
||||
},
|
||||
createReference: async (data: CreateReferenceReq) => {
|
||||
const resp = await client.post('/ai/knowledge/references', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
updateReference: async (id: string, data: UpdateReferenceReq) => {
|
||||
const resp = await client.put(`/ai/knowledge/references/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
deleteReference: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge/references/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
reEmbedReference: async (id: string) => {
|
||||
const resp = await client.post(`/ai/knowledge/references/${id}/re-embed`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Guides
|
||||
listGuides: async (params?: { analysis_type?: string }) => {
|
||||
const resp = await client.get('/ai/knowledge/guides', { params });
|
||||
return resp.data.data as { data: KnowledgeGuide[]; total: number };
|
||||
},
|
||||
createGuide: async (data: CreateGuideReq) => {
|
||||
const resp = await client.post('/ai/knowledge/guides', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
updateGuide: async (id: string, data: UpdateGuideReq) => {
|
||||
const resp = await client.put(`/ai/knowledge/guides/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
deleteGuide: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge/guides/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
reEmbedGuide: async (id: string) => {
|
||||
const resp = await client.post(`/ai/knowledge/guides/${id}/re-embed`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
};
|
||||
188
apps/web/src/api/ai/knowledgeV2.ts
Normal file
188
apps/web/src/api/ai/knowledgeV2.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import client from '../client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
kb_type: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
chunk_strategy: Record<string, unknown>;
|
||||
intent_keywords: Record<string, unknown>;
|
||||
embedding_model: string | null;
|
||||
is_enabled: boolean;
|
||||
document_count: number;
|
||||
chunk_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocument {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
knowledge_base_id: string;
|
||||
title: string;
|
||||
doc_type: string;
|
||||
source_type: string;
|
||||
source_url: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
file_mime_type: string | null;
|
||||
content: string | null;
|
||||
status: string;
|
||||
chunk_count: number;
|
||||
embedded_count: number;
|
||||
error_message: string | null;
|
||||
processing_started_at: string | null;
|
||||
processing_completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
chunk_id: string;
|
||||
document_id: string;
|
||||
chunk_index: number;
|
||||
content: string;
|
||||
doc_title: string;
|
||||
similarity: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateKnowledgeBaseReq {
|
||||
name: string;
|
||||
kb_type: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
chunk_strategy?: Record<string, unknown>;
|
||||
intent_keywords?: Record<string, unknown>;
|
||||
embedding_model?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeBaseReq {
|
||||
name?: string;
|
||||
kb_type?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
chunk_strategy?: Record<string, unknown>;
|
||||
intent_keywords?: Record<string, unknown>;
|
||||
embedding_model?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDocumentReq {
|
||||
kb_id: string;
|
||||
title: string;
|
||||
doc_type?: string;
|
||||
source_type?: string;
|
||||
source_url?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// === API ===
|
||||
|
||||
export const knowledgeV2Api = {
|
||||
// Knowledge Bases
|
||||
listKnowledgeBases: async (params?: {
|
||||
kb_type?: string;
|
||||
is_enabled?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => {
|
||||
const resp = await client.get('/ai/knowledge-bases', { params });
|
||||
return resp.data.data as {
|
||||
data: KnowledgeBase[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
},
|
||||
|
||||
getKnowledgeBase: async (id: string) => {
|
||||
const resp = await client.get(`/ai/knowledge-bases/${id}`);
|
||||
return resp.data.data as KnowledgeBase;
|
||||
},
|
||||
|
||||
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
|
||||
const resp = await client.post('/ai/knowledge-bases', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
|
||||
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
deleteKnowledgeBase: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Documents
|
||||
listDocuments: async (
|
||||
kbId: string,
|
||||
params?: { status?: string; page?: number; page_size?: number },
|
||||
) => {
|
||||
const resp = await client.get(
|
||||
`/ai/knowledge-bases/${kbId}/documents`,
|
||||
{ params },
|
||||
);
|
||||
return resp.data.data as {
|
||||
data: KnowledgeDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
},
|
||||
|
||||
getDocument: async (id: string) => {
|
||||
const resp = await client.get(`/ai/documents/${id}`);
|
||||
return resp.data.data as KnowledgeDocument;
|
||||
},
|
||||
|
||||
createManualDocument: async (data: CreateDocumentReq) => {
|
||||
const resp = await client.post('/ai/documents/manual', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
uploadDocument: async (
|
||||
kbId: string,
|
||||
file: File,
|
||||
title?: string,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('kb_id', kbId);
|
||||
formData.append('file', file);
|
||||
if (title) {
|
||||
formData.append('title', title);
|
||||
}
|
||||
const resp = await client.post('/ai/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
deleteDocument: async (kbId: string, id: string) => {
|
||||
const resp = await client.delete(
|
||||
`/ai/knowledge-bases/${kbId}/documents/${id}`,
|
||||
);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Hit Test
|
||||
hitTest: async (kbId: string, query: string, topK?: number) => {
|
||||
const resp = await client.post('/ai/documents/hit-test', {
|
||||
kb_id: kbId,
|
||||
query,
|
||||
top_k: topK,
|
||||
});
|
||||
return resp.data.data as {
|
||||
query: string;
|
||||
total: number;
|
||||
hits: SearchHit[];
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -18,10 +18,10 @@ export interface UpdateUserRequest {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '') {
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '', excludeOnlyRoles?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
|
||||
'/users',
|
||||
{ params: { page, page_size: pageSize, search: search || undefined } }
|
||||
{ params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Users() {
|
||||
const {
|
||||
data: users, total, page, loading, refresh,
|
||||
} = usePaginatedData<UserInfo>(async (p, pageSize, search) => {
|
||||
const result = await listUsers(p, pageSize, search);
|
||||
const result = await listUsers(p, pageSize, search, 'patient');
|
||||
return { data: result.data, total: result.total };
|
||||
}, 20);
|
||||
|
||||
|
||||
566
apps/web/src/pages/ai/KnowledgeV2Page.tsx
Normal file
566
apps/web/src/pages/ai/KnowledgeV2Page.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Upload,
|
||||
Progress,
|
||||
Drawer,
|
||||
List,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import {
|
||||
knowledgeV2Api,
|
||||
type KnowledgeBase,
|
||||
type KnowledgeDocument,
|
||||
type SearchHit,
|
||||
type CreateKnowledgeBaseReq,
|
||||
} from '../../api/ai/knowledgeV2';
|
||||
|
||||
const KB_TYPES = [
|
||||
{ label: '临床指南', value: 'clinical_guide' },
|
||||
{ label: '操作规程', value: 'sop' },
|
||||
{ label: 'FAQ', value: 'faq' },
|
||||
{ label: '产品知识', value: 'product' },
|
||||
{ label: '通用', value: 'general' },
|
||||
];
|
||||
|
||||
export default function KnowledgeV2Page() {
|
||||
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Document drawer state
|
||||
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
|
||||
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadKbId, setUploadKbId] = useState<string>('');
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// Hit test state
|
||||
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
|
||||
const [hitTestQuery, setHitTestQuery] = useState('');
|
||||
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
|
||||
const [hitTestLoading, setHitTestLoading] = useState(false);
|
||||
|
||||
const loadKbs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await knowledgeV2Api.listKnowledgeBases({
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
setKbs(res.data);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error('加载知识库列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKbs();
|
||||
}, [loadKbs]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const req: CreateKnowledgeBaseReq = {
|
||||
name: values.name,
|
||||
kb_type: values.kb_type,
|
||||
description: values.description,
|
||||
is_enabled: values.is_enabled ?? true,
|
||||
};
|
||||
await knowledgeV2Api.createKnowledgeBase(req);
|
||||
message.success('知识库创建成功');
|
||||
setCreateModalOpen(false);
|
||||
form.resetFields();
|
||||
loadKbs();
|
||||
} catch {
|
||||
// validation error
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editKb) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
|
||||
name: values.name,
|
||||
kb_type: values.kb_type,
|
||||
description: values.description,
|
||||
is_enabled: values.is_enabled,
|
||||
});
|
||||
message.success('知识库更新成功');
|
||||
setEditKb(null);
|
||||
form.resetFields();
|
||||
loadKbs();
|
||||
} catch {
|
||||
// validation error
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await knowledgeV2Api.deleteKnowledgeBase(id);
|
||||
message.success('知识库已删除');
|
||||
loadKbs();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDocuments = async (kb: KnowledgeBase) => {
|
||||
setDocDrawerKb(kb);
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const res = await knowledgeV2Api.listDocuments(kb.id, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
setDocs(res.data);
|
||||
} catch {
|
||||
message.error('加载文档列表失败');
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadKbId || fileList.length === 0) return;
|
||||
try {
|
||||
const file = fileList[0].originFileObj;
|
||||
if (!file) return;
|
||||
await knowledgeV2Api.uploadDocument(uploadKbId, file);
|
||||
message.success('文档上传成功,正在处理...');
|
||||
setUploadModalOpen(false);
|
||||
setFileList([]);
|
||||
if (docDrawerKb) {
|
||||
loadDocuments(docDrawerKb);
|
||||
}
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (kbId: string, docId: string) => {
|
||||
try {
|
||||
await knowledgeV2Api.deleteDocument(kbId, docId);
|
||||
message.success('文档已删除');
|
||||
if (docDrawerKb) {
|
||||
loadDocuments(docDrawerKb);
|
||||
}
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHitTest = async () => {
|
||||
if (!hitTestKb || !hitTestQuery.trim()) return;
|
||||
setHitTestLoading(true);
|
||||
try {
|
||||
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
|
||||
setHitResults(res.hits);
|
||||
} catch {
|
||||
message.error('搜索失败');
|
||||
setHitResults([]);
|
||||
} finally {
|
||||
setHitTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusTag = (status: string) => {
|
||||
const map: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: 'default', label: '待处理' },
|
||||
processing: { color: 'processing', label: '处理中' },
|
||||
completed: { color: 'success', label: '已完成' },
|
||||
failed: { color: 'error', label: '失败' },
|
||||
};
|
||||
const info = map[status] || { color: 'default', label: status };
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
};
|
||||
|
||||
const kbColumns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: KnowledgeBase) => (
|
||||
<Button type="link" onClick={() => loadDocuments(record)}>
|
||||
{name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'kb_type',
|
||||
key: 'kb_type',
|
||||
render: (type: string) => {
|
||||
const found = KB_TYPES.find((t) => t.value === type);
|
||||
return found?.label || type;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文档数',
|
||||
dataIndex: 'document_count',
|
||||
key: 'document_count',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '切片数',
|
||||
dataIndex: 'chunk_count',
|
||||
key: 'chunk_count',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
render: (_: unknown, record: KnowledgeBase) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => {
|
||||
setUploadKbId(record.id);
|
||||
setUploadModalOpen(true);
|
||||
}}
|
||||
>
|
||||
上传
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => {
|
||||
setHitTestKb(record);
|
||||
setHitResults([]);
|
||||
setHitTestQuery('');
|
||||
}}
|
||||
>
|
||||
搜索测试
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditKb(record);
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
kb_type: record.kb_type,
|
||||
description: record.description,
|
||||
is_enabled: record.is_enabled,
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此知识库?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const kbFormContent = (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="知识库名称"
|
||||
rules={[{ required: true, message: '请输入知识库名称' }]}
|
||||
>
|
||||
<Input placeholder="例:高血压临床指南" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="kb_type"
|
||||
label="知识库类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select options={KB_TYPES} placeholder="选择类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
|
||||
const docColumns = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'doc_type',
|
||||
key: 'doc_type',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: statusTag,
|
||||
},
|
||||
{
|
||||
title: '切片/嵌入',
|
||||
key: 'progress',
|
||||
width: 130,
|
||||
render: (_: unknown, record: KnowledgeDocument) => {
|
||||
if (record.chunk_count === 0) return '-';
|
||||
const pct = Math.round(
|
||||
(record.embedded_count / record.chunk_count) * 100,
|
||||
);
|
||||
return <Progress percent={pct} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 70,
|
||||
render: (_: unknown, record: KnowledgeDocument) => (
|
||||
<Popconfirm
|
||||
title="确定删除此文档?"
|
||||
onConfirm={() =>
|
||||
handleDeleteDoc(record.knowledge_base_id, record.id)
|
||||
}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
知识库管理 V2
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建知识库
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={kbColumns}
|
||||
dataSource={kbs}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 创建知识库 Modal */}
|
||||
<Modal
|
||||
title="新建知识库"
|
||||
open={createModalOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
okText="创建"
|
||||
>
|
||||
{kbFormContent}
|
||||
</Modal>
|
||||
|
||||
{/* 编辑知识库 Modal */}
|
||||
<Modal
|
||||
title="编辑知识库"
|
||||
open={!!editKb}
|
||||
onOk={handleUpdate}
|
||||
onCancel={() => {
|
||||
setEditKb(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
okText="保存"
|
||||
>
|
||||
{kbFormContent}
|
||||
</Modal>
|
||||
|
||||
{/* 文档列表 Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
docDrawerKb
|
||||
? `${docDrawerKb.name} — 文档列表`
|
||||
: '文档列表'
|
||||
}
|
||||
open={!!docDrawerKb}
|
||||
onClose={() => setDocDrawerKb(null)}
|
||||
width={720}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={docColumns}
|
||||
dataSource={docs}
|
||||
loading={docsLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description="暂无文档" /> }}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* 上传文档 Modal */}
|
||||
<Modal
|
||||
title="上传文档"
|
||||
open={uploadModalOpen}
|
||||
onOk={handleUpload}
|
||||
onCancel={() => {
|
||||
setUploadModalOpen(false);
|
||||
setFileList([]);
|
||||
}}
|
||||
okText="上传"
|
||||
>
|
||||
<Upload
|
||||
beforeUpload={() => false}
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
onChange={({ fileList: fl }) => setFileList(fl)}
|
||||
accept=".pdf,.txt,.md,.docx,.xlsx"
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||
支持 PDF、TXT、Markdown、DOCX、XLSX,最大 20MB
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Hit Test Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
|
||||
}
|
||||
open={!!hitTestKb}
|
||||
onClose={() => {
|
||||
setHitTestKb(null);
|
||||
setHitResults([]);
|
||||
}}
|
||||
width={600}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="输入搜索文本..."
|
||||
value={hitTestQuery}
|
||||
onChange={(e) => setHitTestQuery(e.target.value)}
|
||||
onPressEnter={handleHitTest}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
loading={hitTestLoading}
|
||||
onClick={handleHitTest}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
<List
|
||||
dataSource={hitResults}
|
||||
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item key={item.chunk_id}>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<span>{item.doc_title}</span>
|
||||
<Tag color="blue">切片 #{item.chunk_index}</Tag>
|
||||
<Tag color="green">
|
||||
相似度 {(item.similarity * 100).toFixed(1)}%
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
knowledgeApi,
|
||||
type KnowledgeReference,
|
||||
type KnowledgeGuide,
|
||||
type CreateReferenceReq,
|
||||
type UpdateReferenceReq,
|
||||
type CreateGuideReq,
|
||||
type UpdateGuideReq,
|
||||
} from '../../api/ai/knowledge';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
|
||||
const ANALYSIS_TYPES = [
|
||||
{ value: 'lab_report', label: '化验报告' },
|
||||
{ value: 'trend', label: '趋势分析' },
|
||||
{ value: 'report_summary', label: '报告摘要' },
|
||||
{ value: 'dialysis_risk', label: '透析风险' },
|
||||
{ value: 'checkup_plan', label: '体检计划' },
|
||||
{ value: 'follow_up', label: '随访总结' },
|
||||
];
|
||||
|
||||
export default function AiKnowledgePage() {
|
||||
return (
|
||||
<Card title="AI 知识库管理">
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'references', label: '参考资料', children: <ReferencesTab /> },
|
||||
{ key: 'guides', label: '临床指南', children: <GuidesTab /> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// === References Tab ===
|
||||
|
||||
function ReferencesTab() {
|
||||
const [data, setData] = useState<KnowledgeReference[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeReference | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | undefined>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await knowledgeApi.listReferences(
|
||||
filterType ? { analysis_type: filterType } : undefined,
|
||||
);
|
||||
setData(result.data);
|
||||
} catch {
|
||||
message.error('加载参考资料失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: KnowledgeReference) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
title: record.title,
|
||||
analysis_type: record.analysis_type,
|
||||
source_name: record.source_name,
|
||||
content_summary: record.content_summary,
|
||||
is_enabled: record.is_enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editing) {
|
||||
const req: UpdateReferenceReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
source_name: values.source_name,
|
||||
content_summary: values.content_summary,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.updateReference(editing.id, req);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
const req: CreateReferenceReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
source_name: values.source_name,
|
||||
content_summary: values.content_summary,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.createReference(req);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.deleteReference(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReEmbed = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.reEmbedReference(id);
|
||||
message.success('向量重新生成已触发');
|
||||
} catch {
|
||||
message.error('向量重新生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '分析类型',
|
||||
dataIndex: 'analysis_type',
|
||||
key: 'analysis_type',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const found = ANALYSIS_TYPES.find((t) => t.value === v);
|
||||
return <Tag>{found?.label ?? v}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 150, ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: KnowledgeReference) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重新生成向量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => handleReEmbed(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此参考资料?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按分析类型过滤"
|
||||
style={{ width: 180 }}
|
||||
options={ANALYSIS_TYPES}
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
/>
|
||||
<AuthButton code="ai.knowledge.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增参考资料
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑参考资料' : '新增参考资料'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="analysis_type"
|
||||
label="分析类型"
|
||||
rules={[{ required: true, message: '请选择分析类型' }]}
|
||||
>
|
||||
<Select options={ANALYSIS_TYPES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="source_name" label="来源名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content_summary"
|
||||
label="内容摘要"
|
||||
rules={[{ required: true, message: '请输入内容摘要' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// === Guides Tab ===
|
||||
|
||||
function GuidesTab() {
|
||||
const [data, setData] = useState<KnowledgeGuide[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeGuide | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | undefined>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await knowledgeApi.listGuides(
|
||||
filterType ? { analysis_type: filterType } : undefined,
|
||||
);
|
||||
setData(result.data);
|
||||
} catch {
|
||||
message.error('加载临床指南失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: KnowledgeGuide) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
title: record.title,
|
||||
analysis_type: record.analysis_type,
|
||||
content: record.content,
|
||||
category: record.category,
|
||||
is_enabled: record.is_enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editing) {
|
||||
const req: UpdateGuideReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
content: values.content,
|
||||
category: values.category,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.updateGuide(editing.id, req);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
const req: CreateGuideReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
content: values.content,
|
||||
category: values.category,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.createGuide(req);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.deleteGuide(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReEmbed = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.reEmbedGuide(id);
|
||||
message.success('向量重新生成已触发');
|
||||
} catch {
|
||||
message.error('向量重新生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '分析类型',
|
||||
dataIndex: 'analysis_type',
|
||||
key: 'analysis_type',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const found = ANALYSIS_TYPES.find((t) => t.value === v);
|
||||
return <Tag>{found?.label ?? v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 100,
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: KnowledgeGuide) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重新生成向量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => handleReEmbed(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此临床指南?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按分析类型过滤"
|
||||
style={{ width: 180 }}
|
||||
options={ANALYSIS_TYPES}
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
/>
|
||||
<AuthButton code="ai.knowledge.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增临床指南
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑临床指南' : '新增临床指南'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
width={700}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="analysis_type"
|
||||
label="分析类型"
|
||||
rules={[{ required: true, message: '请选择分析类型' }]}
|
||||
>
|
||||
<Select options={ANALYSIS_TYPES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input placeholder="如:心血管、内分泌" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="指南内容"
|
||||
rules={[{ required: true, message: '请输入指南内容' }]}
|
||||
>
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
MedicineBoxOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
import HealthDataCenter from './HealthDataCenter';
|
||||
|
||||
export function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData();
|
||||
const patientCount = useCountUp(patientStats?.total_patients ?? 0);
|
||||
const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0);
|
||||
@@ -18,39 +20,51 @@ export function AdminDashboard() {
|
||||
|
||||
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
const newThisMonth = patientStats?.new_this_month ?? 0;
|
||||
const newThisWeek = patientStats?.new_this_week ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>管理中心</Typography.Title>
|
||||
<Typography.Text type="secondary">数据概览</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
患者增长 {newThisMonth > 0 ? `本月+${newThisMonth}` : ''} · 本周+{newThisWeek} · 活跃 {patientStats?.active_this_month ?? 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
|
||||
<Statistic title="患者总数" value={patientCount} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/appointments')}>
|
||||
<Statistic title="本月预约" value={appointmentCount} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
|
||||
<Statistic
|
||||
title="随访完成"
|
||||
value={followUpStats?.completion_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
suffix={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
% {followUpStats && followUpStats.overdue > 0 && (
|
||||
<Typography.Text type="danger" style={{ fontSize: 11 }}>({followUpStats.overdue}逾期)</Typography.Text>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
|
||||
<Statistic
|
||||
title="体征上报"
|
||||
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
|
||||
@@ -61,10 +75,20 @@ export function AdminDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/doctors')}>
|
||||
<Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
|
||||
<Statistic
|
||||
title="咨询待回复"
|
||||
value={healthDataStats ? 0 : 0}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#d97706' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 健康数据中心 Tab */}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex, Space, Bu
|
||||
import {
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MedicineBoxOutlined,
|
||||
CalendarOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
AlertOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -137,7 +138,7 @@ export function DoctorDashboard() {
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
|
||||
<Statistic
|
||||
title="我的患者"
|
||||
value={myPatientsCount}
|
||||
@@ -151,19 +152,22 @@ export function DoctorDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
|
||||
<Statistic
|
||||
title="随访完成率"
|
||||
value={p?.follow_up_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
styles={{ content: { color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' } }}
|
||||
title="今日预约"
|
||||
value={p?.today_appointments ?? 0}
|
||||
prefix={<CalendarOutlined />}
|
||||
suffix={p?.yesterday_today_appointments != null ? (() => {
|
||||
const diff = (p.today_appointments ?? 0) - (p.yesterday_today_appointments ?? 0);
|
||||
if (diff > 0) return <Typography.Text type="success" style={{ fontSize: 12 }}><ArrowUpOutlined /> {diff}</Typography.Text>;
|
||||
if (diff < 0) return <Typography.Text type="danger" style={{ fontSize: 12 }}><ArrowDownOutlined /> {Math.abs(diff)}</Typography.Text>;
|
||||
return null;
|
||||
})() : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
|
||||
<Statistic
|
||||
title="本月咨询"
|
||||
value={consultationsCount}
|
||||
@@ -177,7 +181,7 @@ export function DoctorDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
|
||||
<Statistic
|
||||
title="体征上报率"
|
||||
value={p?.vital_signs_report_rate ?? 0}
|
||||
@@ -192,13 +196,18 @@ export function DoctorDashboard() {
|
||||
{/* 化验审核 */}
|
||||
{p && p.pending_lab_reviews > 0 && (
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无待审核化验' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
<Card
|
||||
title={`化验审核 (${p.pending_lab_reviews}待审)`}
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/patients')}>
|
||||
查看待审
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography.Text type="secondary">
|
||||
您有 {p.pending_lab_reviews} 份化验报告待审核,请及时处理。
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
@@ -211,7 +220,7 @@ export function DoctorDashboard() {
|
||||
dataSource={activeConsultations}
|
||||
locale={{ emptyText: '暂无未读消息' }}
|
||||
renderItem={(session) => (
|
||||
<List.Item style={{ padding: '6px 0' }}>
|
||||
<List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/consultations/${session.id}`)}>
|
||||
<Space>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text>
|
||||
<Tag color={session.status === 'active' ? 'green' : 'default'}>{session.status === 'active' ? '进行中' : session.status}</Tag>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
TeamOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -11,6 +12,7 @@ import { followUpApi, type FollowUpTask } from '../../../api/health/followUp';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function NurseDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [personal, setPersonal] = useState<PersonalStats | null>(null);
|
||||
const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -66,7 +68,7 @@ export function NurseDashboard() {
|
||||
{p.abnormal_vital_signs} 位患者体征异常
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Link>查看全部 →</Typography.Link>
|
||||
<Typography.Link onClick={() => navigate('/health/alert-dashboard')}>查看全部 →</Typography.Link>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd';
|
||||
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex, Button } from 'antd';
|
||||
import {
|
||||
TrophyOutlined,
|
||||
FileTextOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { articleApi, type ArticleListItem } from '../../../api/health/articles';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function OperatorDashboard() {
|
||||
const { pointsStats, loading } = useStatsData();
|
||||
const navigate = useNavigate();
|
||||
const { pointsStats, offlineEventCount, loading } = useStatsData();
|
||||
const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]);
|
||||
const issuedCount = useCountUp(pointsStats?.total_issued ?? 0);
|
||||
const spentCount = useCountUp(pointsStats?.total_spent ?? 0);
|
||||
const activeCount = useCountUp(pointsStats?.active_accounts ?? 0);
|
||||
const offlineCount = useCountUp(offlineEventCount);
|
||||
|
||||
const fetchTopArticles = useCallback(async () => {
|
||||
try {
|
||||
@@ -26,6 +30,8 @@ export function OperatorDashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// fetchTopArticles 内部 async setState(外部数据获取),非同步派生 state,属合理 effect 用法
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]);
|
||||
|
||||
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
@@ -41,12 +47,12 @@ export function OperatorDashboard() {
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||||
<Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||||
<Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />}
|
||||
suffix={pointsStats ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
@@ -57,26 +63,34 @@ export function OperatorDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||||
<Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} />
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/offline-events')}>
|
||||
<Statistic title="线下活动" value={offlineCount} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 积分排行 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="积分消费排行" size="small">
|
||||
<Card
|
||||
title="积分消费排行"
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/points-rules')}>
|
||||
查看全部
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={(item, idx) => (
|
||||
<List.Item>
|
||||
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text>
|
||||
<List.Item style={{ cursor: 'pointer' }} onClick={() => navigate(`/health/patients/${item.patient_id}`)}>
|
||||
<Typography.Text>{idx + 1}. {item.patient_name}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.total_earned} 分</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
@@ -86,13 +100,21 @@ export function OperatorDashboard() {
|
||||
|
||||
{/* 热门文章 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="热门文章" size="small">
|
||||
<Card
|
||||
title="热门文章"
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/articles')}>
|
||||
内容管理
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={topArticles}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={(article) => (
|
||||
<List.Item style={{ padding: '6px 0' }}>
|
||||
<List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/articles/${article.id}/edit`)}>
|
||||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{article.view_count} 次阅读
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface StatsData {
|
||||
healthDataStats: HealthDataStats | null;
|
||||
dialysisStats: DialysisStatistics | null;
|
||||
doctorCount: number;
|
||||
offlineEventCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
@@ -40,6 +41,7 @@ export function useStatsData(): StatsData {
|
||||
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
||||
const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null);
|
||||
const [doctorCount, setDoctorCount] = useState(0);
|
||||
const [offlineEventCount, setOfflineEventCount] = useState(0);
|
||||
|
||||
const fetchAllStats = useCallback(async () => {
|
||||
// 缓存未过期,直接使用
|
||||
@@ -52,6 +54,7 @@ export function useStatsData(): StatsData {
|
||||
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
|
||||
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
|
||||
setDoctorCount(c.doctorCount as number);
|
||||
setOfflineEventCount(c.offlineEventCount as number);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -66,6 +69,7 @@ export function useStatsData(): StatsData {
|
||||
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
|
||||
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
|
||||
setDoctorCount(c.doctorCount as number);
|
||||
setOfflineEventCount(c.offlineEventCount as number);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -86,6 +90,7 @@ export function useStatsData(): StatsData {
|
||||
healthDataStats: null,
|
||||
dialysisStats: null,
|
||||
doctorCount: 0,
|
||||
offlineEventCount: 0,
|
||||
};
|
||||
|
||||
const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => {
|
||||
@@ -110,14 +115,19 @@ export function useStatsData(): StatsData {
|
||||
'doctorCount',
|
||||
'医护',
|
||||
),
|
||||
tryFetch(
|
||||
async () => { const r = await pointsApi.listOfflineEvents({ page: 1, page_size: 1 }); return r.total; },
|
||||
'offlineEventCount',
|
||||
'线下活动',
|
||||
),
|
||||
]);
|
||||
|
||||
if (!hasAnyError || errors.length < 7) {
|
||||
if (!hasAnyError || errors.length < 8) {
|
||||
cachedStats = results;
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
|
||||
if (hasAnyError && errors.length === 7) {
|
||||
if (hasAnyError && errors.length === 8) {
|
||||
setError('加载统计数据失败');
|
||||
}
|
||||
|
||||
@@ -133,6 +143,7 @@ export function useStatsData(): StatsData {
|
||||
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
|
||||
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
|
||||
setDoctorCount(c.doctorCount as number);
|
||||
setOfflineEventCount(c.offlineEventCount as number);
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
setLoading(false);
|
||||
@@ -144,7 +155,7 @@ export function useStatsData(): StatsData {
|
||||
}, [fetchAllStats]);
|
||||
|
||||
return {
|
||||
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount,
|
||||
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount, offlineEventCount,
|
||||
loading, error, refresh: fetchAllStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +240,14 @@ where
|
||||
let provider_name = provider_arc.name().to_string();
|
||||
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
|
||||
|
||||
// 收集 token 和 display_hints
|
||||
#[allow(unused_assignments)]
|
||||
let mut input_tokens: u32 = 0;
|
||||
#[allow(unused_assignments)]
|
||||
let mut output_tokens: u32 = 0;
|
||||
let mut duration_ms: u64 = 0;
|
||||
let mut collected_hints: Option<Vec<crate::agent::tool::DisplayHint>> = None;
|
||||
|
||||
let result = if supports_fc {
|
||||
// FC provider:执行完整 Agent ReAct 循环
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
@@ -256,6 +264,11 @@ where
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
input_tokens = agent_result.total_input_tokens;
|
||||
output_tokens = agent_result.total_output_tokens;
|
||||
if !agent_result.display_hints.is_empty() {
|
||||
collected_hints = Some(agent_result.display_hints);
|
||||
}
|
||||
agent_result.reply
|
||||
} else {
|
||||
// 非 FC provider:降级为普通对话
|
||||
@@ -279,6 +292,9 @@ where
|
||||
tracing::error!(error = %e, "AI generate failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
input_tokens = resp.input_tokens;
|
||||
output_tokens = resp.output_tokens;
|
||||
duration_ms = resp.duration_ms;
|
||||
resp.content
|
||||
};
|
||||
|
||||
@@ -297,7 +313,7 @@ where
|
||||
"AI chat response sent"
|
||||
);
|
||||
|
||||
// 记录用量的 token 消耗(简化模式下无法精确计量,记 0)
|
||||
// 记录用量的 token 消耗
|
||||
if let Err(e) = ai_state
|
||||
.usage
|
||||
.log_usage(
|
||||
@@ -305,9 +321,9 @@ where
|
||||
&provider_name,
|
||||
&run_params.model,
|
||||
"chat",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
duration_ms,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
@@ -362,7 +378,7 @@ where
|
||||
reply,
|
||||
message_id,
|
||||
iterations: if supports_fc { 1 } else { 0 },
|
||||
display_hints: None,
|
||||
display_hints: collected_hints,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod structured_source;
|
||||
pub mod v2_source;
|
||||
pub mod vector_search;
|
||||
pub mod vector_source;
|
||||
|
||||
|
||||
166
crates/erp-ai/src/knowledge/v2_source.rs
Normal file
166
crates/erp-ai/src/knowledge/v2_source.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{KnowledgeContext, KnowledgeQuery, KnowledgeSource, Reference};
|
||||
use crate::error::AiResult;
|
||||
use crate::service::embedding::EmbeddingService;
|
||||
use crate::service::knowledge_v2::KnowledgeV2Service;
|
||||
|
||||
/// 知识库 V2 向量检索源 — 基于 ai_knowledge_chunks + pgvector
|
||||
pub struct KnowledgeV2Source {
|
||||
db: DatabaseConnection,
|
||||
knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
}
|
||||
|
||||
impl KnowledgeV2Source {
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
knowledge_v2,
|
||||
embedding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KnowledgeSource for KnowledgeV2Source {
|
||||
async fn get_context(&self, query: &KnowledgeQuery) -> AiResult<KnowledgeContext> {
|
||||
let query_text = match &query.query_text {
|
||||
Some(t) if !t.trim().is_empty() => t.clone(),
|
||||
_ => {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !self.embedding.is_configured() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
// 查找租户下所有启用的知识库
|
||||
let kb_ids = get_enabled_kb_ids(&self.db, query.tenant_id).await?;
|
||||
|
||||
if kb_ids.is_empty() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
let embedding = match self.embedding.embed(&query_text).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "KnowledgeV2 Source embedding 失败");
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 在所有知识库中搜索,取最佳结果
|
||||
let mut all_hits = Vec::new();
|
||||
for kb_id in &kb_ids {
|
||||
if let Ok(hits) = self
|
||||
.knowledge_v2
|
||||
.vector_search(query.tenant_id, *kb_id, &embedding, 5)
|
||||
.await
|
||||
{
|
||||
all_hits.extend(hits);
|
||||
}
|
||||
}
|
||||
|
||||
// 按相似度排序,取 top 10
|
||||
all_hits.sort_by(|a, b| {
|
||||
b.similarity
|
||||
.partial_cmp(&a.similarity)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
all_hits.truncate(10);
|
||||
|
||||
if all_hits.is_empty() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
let max_confidence = all_hits[0].similarity as f32;
|
||||
|
||||
let context_parts: Vec<String> = all_hits
|
||||
.iter()
|
||||
.map(|h| {
|
||||
format!(
|
||||
"[文档: {} | 相似度: {:.2}]\n{}",
|
||||
h.doc_title, h.similarity, h.content
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let references: Vec<Reference> = all_hits
|
||||
.iter()
|
||||
.map(|h| Reference {
|
||||
title: h.doc_title.clone(),
|
||||
source: format!("chunk_{}", h.chunk_index),
|
||||
relevance_score: h.similarity as f32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: context_parts.join("\n\n"),
|
||||
references,
|
||||
confidence: max_confidence,
|
||||
})
|
||||
}
|
||||
|
||||
fn source_type(&self) -> &str {
|
||||
"knowledge_v2"
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> AiResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_enabled_kb_ids(db: &DatabaseConnection, tenant_id: Uuid) -> AiResult<Vec<Uuid>> {
|
||||
#[derive(sea_orm::FromQueryResult)]
|
||||
struct KbIdRow {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
let results: Vec<KbIdRow> = sea_orm::FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT id FROM ai_knowledge_bases WHERE tenant_id = $1 AND is_enabled = true AND deleted_at IS NULL",
|
||||
[sea_orm::Value::from(tenant_id)],
|
||||
),
|
||||
)
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e: sea_orm::DbErr| crate::error::AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(results.into_iter().map(|r| r.id).collect())
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub struct AnalysisService {
|
||||
pub sanitizer: SanitizationService,
|
||||
pub renderer: PromptRenderer,
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub knowledge_source: Option<std::sync::Arc<dyn KnowledgeSource>>,
|
||||
pub knowledge_sources: Vec<std::sync::Arc<dyn KnowledgeSource>>,
|
||||
}
|
||||
|
||||
impl AnalysisService {
|
||||
@@ -34,12 +34,12 @@ impl AnalysisService {
|
||||
sanitizer: SanitizationService::new(),
|
||||
renderer: PromptRenderer::new(),
|
||||
db,
|
||||
knowledge_source: None,
|
||||
knowledge_sources: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_knowledge_source(mut self, source: std::sync::Arc<dyn KnowledgeSource>) -> Self {
|
||||
self.knowledge_source = Some(source);
|
||||
self.knowledge_sources.push(source);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -100,42 +100,47 @@ impl AnalysisService {
|
||||
例如:\"根据临床指南 [ref:uuid-of-guideline],建议...\"\n\
|
||||
每个引用的知识库条目必须在回答中标注。如果没有引用任何知识库条目,则无需标注。";
|
||||
|
||||
let system_prompt = if let Some(ref ks) = self.knowledge_source {
|
||||
let system_prompt = if !self.knowledge_sources.is_empty() {
|
||||
let query = crate::knowledge::KnowledgeQuery {
|
||||
tenant_id,
|
||||
analysis_type: analysis_type.as_str().to_string(),
|
||||
patient_context: None,
|
||||
query_text: None,
|
||||
};
|
||||
match ks.get_context(&query).await {
|
||||
Ok(ctx) if ctx.confidence > 0.0 => {
|
||||
tracing::info!(
|
||||
source = %ctx.source,
|
||||
confidence = ctx.confidence,
|
||||
"知识库上下文注入"
|
||||
);
|
||||
// 将引用的来源 ID 附加到上下文中
|
||||
let refs_info = if ctx.references.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let refs_list: Vec<String> = ctx
|
||||
.references
|
||||
.iter()
|
||||
.map(|r| format!("- {} (ID: {})", r.title, r.source))
|
||||
.collect();
|
||||
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
|
||||
};
|
||||
format!(
|
||||
"{}\n\n=== 知识库参考 ===\n{}{}{}",
|
||||
system_prompt, ctx.context_text, refs_info, citation_instruction
|
||||
)
|
||||
}
|
||||
Ok(_) => system_prompt,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "知识库查询失败,跳过注入");
|
||||
system_prompt
|
||||
let mut best_ctx: Option<crate::knowledge::KnowledgeContext> = None;
|
||||
for ks in &self.knowledge_sources {
|
||||
if let Ok(ctx) = ks.get_context(&query).await
|
||||
&& ctx.confidence > 0.0
|
||||
{
|
||||
match &best_ctx {
|
||||
Some(bc) if bc.confidence >= ctx.confidence => {}
|
||||
_ => best_ctx = Some(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = best_ctx {
|
||||
tracing::info!(
|
||||
source = %ctx.source,
|
||||
confidence = ctx.confidence,
|
||||
"知识库上下文注入"
|
||||
);
|
||||
let refs_info = if ctx.references.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let refs_list: Vec<String> = ctx
|
||||
.references
|
||||
.iter()
|
||||
.map(|r| format!("- {} (ID: {})", r.title, r.source))
|
||||
.collect();
|
||||
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
|
||||
};
|
||||
format!(
|
||||
"{}\n\n=== 知识库参考 ===\n{}{}{}",
|
||||
system_prompt, ctx.context_text, refs_info, citation_instruction
|
||||
)
|
||||
} else {
|
||||
system_prompt
|
||||
}
|
||||
} else {
|
||||
// 无知识库时也添加引用指令(供通用场景使用)
|
||||
format!("{}{}", system_prompt, citation_instruction)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement, TransactionTrait};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis_queue;
|
||||
@@ -93,43 +93,74 @@ impl AnalysisQueue {
|
||||
&self,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> AiResult<Option<ai_analysis_queue::Model>> {
|
||||
let sql = match tenant_id {
|
||||
Some(tid) => format!(
|
||||
"SELECT * FROM ai_analysis_queue WHERE tenant_id = '{}' AND status = 'pending' AND deleted_at IS NULL AND scheduled_at <= NOW() ORDER BY priority DESC, scheduled_at ASC LIMIT 1",
|
||||
tid
|
||||
),
|
||||
None => r#"
|
||||
SELECT * FROM ai_analysis_queue
|
||||
WHERE status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
"#
|
||||
.to_string(),
|
||||
};
|
||||
// 事务内 SELECT ... FOR UPDATE SKIP LOCKED + UPDATE:
|
||||
// - 参数化($1)消除原 format! 拼 tenant_id 的 SQL 注入风险
|
||||
// - FOR UPDATE SKIP LOCKED 在事务内持行锁到 UPDATE 完成,防多消费者并发重复 claim
|
||||
let claimed = self
|
||||
.db
|
||||
.transaction::<_, Option<ai_analysis_queue::Model>, AiError>(|txn| {
|
||||
Box::pin(async move {
|
||||
let row: Option<QueueRow> = match tenant_id {
|
||||
Some(tid) => {
|
||||
QueueRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT * FROM ai_analysis_queue
|
||||
WHERE tenant_id = $1
|
||||
AND status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED"#,
|
||||
[tid.into()],
|
||||
))
|
||||
.one(txn)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
QueueRow::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT * FROM ai_analysis_queue
|
||||
WHERE status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED"#
|
||||
.to_string(),
|
||||
))
|
||||
.one(txn)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql.to_string(),
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let now = chrono::Utc::now();
|
||||
let mut active: ai_analysis_queue::ActiveModel =
|
||||
self.find_by_id(r.id).await?.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.started_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
let model = active.update(&self.db).await?;
|
||||
Ok(Some(model))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
match row {
|
||||
Some(r) => {
|
||||
let now = chrono::Utc::now();
|
||||
let model = ai_analysis_queue::Entity::find_by_id(r.id)
|
||||
.one(txn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AiError::QueueError(format!("队列任务 {} 未找到", r.id))
|
||||
})?;
|
||||
let mut active: ai_analysis_queue::ActiveModel = model.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.started_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
let updated = active.update(txn).await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sea_orm::TransactionError::Connection(d) => d.into(),
|
||||
sea_orm::TransactionError::Transaction(a) => a,
|
||||
})?;
|
||||
Ok(claimed)
|
||||
}
|
||||
|
||||
pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {
|
||||
|
||||
321
crates/erp-ai/src/service/analysis_worker.rs
Normal file
321
crates/erp-ai/src/service/analysis_worker.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! AI 分析队列消费者 — 把 pending 队列任务驱动到 completed/failed。
|
||||
//!
|
||||
//! `module.rs` 的事件入队 + `auto_analysis.rs` 的定时入队把任务写入 `ai_analysis_queue`,
|
||||
//! 但 `claim_next` 此前无人调用,所有任务永远 pending(违反「每个事件必须有消费者」铁律)。
|
||||
//!
|
||||
//! 本 worker 在后台循环 claim → 路由处理 → mark_completed / mark_failed,
|
||||
//! 把 erp-health 触发的分析链路真正打通(MVP 聚焦趋势分析 trend,其他类型暂 skip)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use erp_core::health_provider::TimeRange;
|
||||
use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::AnalysisType;
|
||||
use crate::entity::ai_analysis_queue;
|
||||
use crate::error::{AiError, AiResult};
|
||||
use crate::service::analysis_queue::AnalysisQueue;
|
||||
use crate::state::AiState;
|
||||
|
||||
/// 轮询间隔:无任务时休眠 10 秒避免空转
|
||||
const IDLE_SLEEP: Duration = Duration::from_secs(10);
|
||||
|
||||
/// 启动 AI 分析队列消费者(后台 tokio 任务)。
|
||||
///
|
||||
/// 不阻塞调用方:`tokio::spawn` 后立即返回。
|
||||
/// 在 `erp-server/src/main.rs` 中与 `start_auto_analysis` 一起启动。
|
||||
pub fn start_analysis_worker(state: AiState) {
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("AI 分析队列消费者已启动(轮询间隔 {:?})", IDLE_SLEEP);
|
||||
loop {
|
||||
match process_once(&state).await {
|
||||
Ok(Processed) => {
|
||||
// 立即尝试下一个任务,不等待
|
||||
}
|
||||
Ok(Idle) => {
|
||||
tokio::time::sleep(IDLE_SLEEP).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "分析队列消费循环异常,休眠后重试");
|
||||
tokio::time::sleep(IDLE_SLEEP).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum ProcessOutcome {
|
||||
/// 成功处理了一个任务(或已路由 skip),立刻尝试下一个
|
||||
Processed,
|
||||
/// 队列空,进入休眠
|
||||
Idle,
|
||||
}
|
||||
|
||||
use ProcessOutcome::{Idle, Processed};
|
||||
|
||||
async fn process_once(state: &AiState) -> AiResult<ProcessOutcome> {
|
||||
let queue = AnalysisQueue::new(state.db.clone());
|
||||
let job = match queue.claim_next(None).await? {
|
||||
Some(j) => j,
|
||||
None => return Ok(Idle),
|
||||
};
|
||||
|
||||
let job_id = job.id;
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
tenant_id = %job.tenant_id,
|
||||
patient_id = %job.patient_id,
|
||||
analysis_type = %job.analysis_type,
|
||||
source_event = ?job.source_event,
|
||||
"已领取分析队列任务,开始处理"
|
||||
);
|
||||
|
||||
match job.analysis_type.as_str() {
|
||||
"trend" => handle_trend(state, &queue, job).await,
|
||||
other => {
|
||||
// MVP 阶段:非 trend 类型暂不支持自动消费。
|
||||
// 不写假数据,不标 completed(保留 pending 等未来扩展消费者),
|
||||
// 只记日志后回滚 running → pending 让任务可被未来的处理器接手。
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
analysis_type = %other,
|
||||
"MVP 暂不支持的分析类型,跳过(保持 pending 供未来消费者处理)"
|
||||
);
|
||||
// 回滚事务:claim_next 是事务化的,这里只更新状态不置 completed
|
||||
rollback_running_to_pending(state, job_id).await?;
|
||||
Ok(Processed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 running 状态的任务回滚为 pending(用于 MVP 不支持的类型)。
|
||||
///
|
||||
/// 注意:retry_count 不递增(这是路由跳过而非处理失败),max_retries 不应被消耗。
|
||||
async fn rollback_running_to_pending(state: &AiState, job_id: Uuid) -> AiResult<()> {
|
||||
use sea_orm::ActiveModelTrait;
|
||||
use sea_orm::EntityTrait;
|
||||
use sea_orm::Set;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let entity = ai_analysis_queue::Entity::find_by_id(job_id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::QueueError(format!("队列任务 {job_id} 未找到")))?;
|
||||
let mut active: ai_analysis_queue::ActiveModel = entity.into();
|
||||
active.status = Set("pending".to_string());
|
||||
active.started_at = Set(None);
|
||||
active.updated_at = Set(now);
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&state.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 趋势分析:复刻 handler 的 stream_trends 链路,drain 流式结果后 complete。
|
||||
async fn handle_trend(
|
||||
state: &AiState,
|
||||
queue: &AnalysisQueue,
|
||||
job: ai_analysis_queue::Model,
|
||||
) -> AiResult<ProcessOutcome> {
|
||||
let job_id = job.id;
|
||||
let tenant_id = job.tenant_id;
|
||||
let patient_id = job.patient_id;
|
||||
|
||||
// 失败统一走 mark_failed(自带 retry_count/max_retries 重试逻辑)
|
||||
match run_trend_analysis(state, tenant_id, patient_id).await {
|
||||
Ok(analysis_id) => match queue.mark_completed(job_id, analysis_id).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
analysis_id = %analysis_id,
|
||||
"趋势分析任务完成"
|
||||
);
|
||||
Ok(Processed)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = %job_id, error = %e, "mark_completed 失败");
|
||||
Ok(Processed)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
tracing::warn!(
|
||||
job_id = %job_id,
|
||||
patient_id = %patient_id,
|
||||
error = %err_msg,
|
||||
"趋势分析处理失败"
|
||||
);
|
||||
match queue.mark_failed(job_id, err_msg).await {
|
||||
Ok(()) => {}
|
||||
Err(mfe) => {
|
||||
tracing::warn!(job_id = %job_id, error = %mfe, "mark_failed 本身失败");
|
||||
}
|
||||
}
|
||||
Ok(Processed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一次趋势分析,返回新建的 analysis_id。
|
||||
///
|
||||
/// 流程对齐 `handler::stream_trends` + `build_sse_stream`:
|
||||
/// 取趋势数据 → sanitize → 加载 prompt → stream_analyze → drain 流 → complete_analysis。
|
||||
async fn run_trend_analysis(state: &AiState, tenant_id: Uuid, patient_id: Uuid) -> AiResult<Uuid> {
|
||||
let metrics = vec![
|
||||
"systolic_bp_morning".to_string(),
|
||||
"diastolic_bp_morning".to_string(),
|
||||
"heart_rate".to_string(),
|
||||
"weight".to_string(),
|
||||
"blood_sugar".to_string(),
|
||||
];
|
||||
let range = TimeRange {
|
||||
start: chrono::Utc::now() - chrono::Duration::days(90),
|
||||
end: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let trend_data = state
|
||||
.health_provider
|
||||
.get_trend_analysis_data(tenant_id, patient_id, &metrics, &range)
|
||||
.await
|
||||
.map_err(|e| AiError::ProviderError(format!("获取趋势数据失败: {e}")))?;
|
||||
|
||||
if trend_data.metrics.is_empty() {
|
||||
// 数据为空不是程序错误,但分析无法进行 → 返回失败让队列走重试/最终失败
|
||||
return Err(AiError::ProviderError(
|
||||
"患者在选定时间段内无体征监测数据".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_data = state
|
||||
.analysis
|
||||
.sanitizer
|
||||
.sanitize_trend_analysis(&trend_data)?;
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(tenant_id, AnalysisType::Trends.prompt_name())
|
||||
.await?;
|
||||
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(&prompt.model_config, tenant_id, &state.db).await;
|
||||
|
||||
// 队列任务无 HTTP 上下文,user_id 用 nil 占位(仅用于审计记录)
|
||||
let system_user = Uuid::nil();
|
||||
let (stream, analysis_id, _provider) = state
|
||||
.analysis
|
||||
.stream_analyze(
|
||||
tenant_id,
|
||||
system_user,
|
||||
patient_id,
|
||||
AnalysisType::Trends,
|
||||
patient_id.to_string(),
|
||||
prompt.system_prompt,
|
||||
prompt.user_prompt_template,
|
||||
sanitized_data,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// drain 流:累积全部输出,遇错 fail_analysis
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut full_content = String::new();
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(chunk) => full_content.push_str(&chunk),
|
||||
Err(e) => {
|
||||
let _ = state
|
||||
.analysis
|
||||
.fail_analysis(analysis_id, e.to_string())
|
||||
.await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({ "analysis_type": "trend", "source": "queue_worker" });
|
||||
state
|
||||
.analysis
|
||||
.complete_analysis(analysis_id, full_content.clone(), metadata.clone())
|
||||
.await?;
|
||||
|
||||
// 用量记录(4 字符 ≈ 1 token 估算,对齐 SSE handler 逻辑)
|
||||
let est_output_tokens = (full_content.len() as u32) / 4;
|
||||
if let Err(e) = state
|
||||
.usage
|
||||
.log_usage(
|
||||
tenant_id,
|
||||
"queue_worker",
|
||||
"",
|
||||
"trend",
|
||||
0,
|
||||
est_output_tokens,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "队列消费者记录用量失败");
|
||||
}
|
||||
|
||||
// 后处理(解析建议、发布事件等)— 与 SSE handler 一致
|
||||
crate::service::post_process::post_process_analysis(
|
||||
state,
|
||||
analysis_id,
|
||||
&full_content,
|
||||
tenant_id,
|
||||
patient_id,
|
||||
system_user,
|
||||
"trend",
|
||||
metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(analysis_id)
|
||||
}
|
||||
|
||||
/// 解析 prompt.model_config + 租户默认配置,返回 (model, temperature, max_tokens)。
|
||||
///
|
||||
/// 与 `handler::resolve_model_config` 实现等价(独立复制避免跨模块可见性问题)。
|
||||
async fn resolve_model_config(
|
||||
model_config: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> (String, f32, u32) {
|
||||
let defaults = crate::config_resolver::load_ai_config(tenant_id, db).await;
|
||||
let analysis = &defaults.analysis_defaults;
|
||||
|
||||
let model = model_config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&analysis.model)
|
||||
.to_string();
|
||||
let temperature = model_config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(analysis.temperature as f64) as f32;
|
||||
let max_tokens = model_config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(analysis.max_tokens as u64) as u32;
|
||||
|
||||
(model, temperature, max_tokens)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn idle_sleep_为10秒() {
|
||||
assert_eq!(IDLE_SLEEP.as_secs(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_outcome_枚举可用() {
|
||||
let _a = Processed;
|
||||
let _b = Idle;
|
||||
}
|
||||
}
|
||||
87
crates/erp-ai/src/service/document/chunker.rs
Normal file
87
crates/erp-ai/src/service/document/chunker.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
/// 文本切片:按固定大小 + 重叠切分
|
||||
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
|
||||
if text.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let total = chars.len();
|
||||
|
||||
if total <= chunk_size {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut start = 0;
|
||||
|
||||
while start < total {
|
||||
let end = (start + chunk_size).min(total);
|
||||
let chunk: String = chars[start..end].iter().collect();
|
||||
|
||||
let trimmed = chunk.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
chunks.push(trimmed);
|
||||
}
|
||||
|
||||
if end >= total {
|
||||
break;
|
||||
}
|
||||
start += chunk_size.saturating_sub(overlap);
|
||||
|
||||
// 防止无限循环
|
||||
if start <= end - chunk_size && start > 0 {
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_empty() {
|
||||
assert_eq!(chunk_text("", 100, 20), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_small_text() {
|
||||
let text = "hello world";
|
||||
let chunks = chunk_text(text, 100, 20);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_long_text() {
|
||||
let text = "abcdefghij".repeat(100); // 1000 chars
|
||||
let chunks = chunk_text(&text, 200, 50);
|
||||
assert!(chunks.len() > 1);
|
||||
// First chunk should be 200 chars
|
||||
assert_eq!(chars_count(&chunks[0]), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_with_overlap() {
|
||||
let text = "abcdefghijklmnopqrstuvwxyz".repeat(20); // 520 chars
|
||||
let chunks = chunk_text(&text, 100, 20);
|
||||
assert!(chunks.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_chinese() {
|
||||
let text = "你好世界这是一段中文测试文本。".repeat(30);
|
||||
let chunks = chunk_text(&text, 100, 20);
|
||||
assert!(chunks.len() > 1);
|
||||
// 确保中文不被截断
|
||||
for chunk in &chunks {
|
||||
assert!(!chunk.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn chars_count(s: &str) -> usize {
|
||||
s.chars().count()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analysis;
|
||||
pub mod analysis_queue;
|
||||
pub mod analysis_worker;
|
||||
pub mod auto_analysis;
|
||||
pub mod cache;
|
||||
pub mod chat_message;
|
||||
@@ -7,6 +8,7 @@ pub mod chat_session;
|
||||
pub mod comparison;
|
||||
pub mod cost;
|
||||
pub mod dialysis_risk_scorer;
|
||||
pub mod document;
|
||||
pub mod embedding;
|
||||
pub mod feature_flag_service;
|
||||
pub mod insight_service;
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct UserListParams {
|
||||
pub page_size: Option<u64>,
|
||||
/// Optional search term — filters by username (case-insensitive contains).
|
||||
pub search: Option<String>,
|
||||
/// Exclude users whose *only* role is one of these comma-separated role codes.
|
||||
/// Example: `exclude_only_roles=patient` hides users that have no role other than "patient".
|
||||
pub exclude_only_roles: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -54,10 +57,17 @@ where
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let exclude_only_roles: Option<Vec<String>> = params
|
||||
.exclude_only_roles
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect());
|
||||
|
||||
let (users, total) = UserService::list(
|
||||
ctx.tenant_id,
|
||||
&pagination,
|
||||
params.search.as_deref(),
|
||||
exclude_only_roles.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -19,8 +19,54 @@ type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
|
||||
static USER_SCOPE_CACHE: std::sync::LazyLock<DashMap<uuid::Uuid, ScopeCacheEntry>> =
|
||||
std::sync::LazyLock::new(DashMap::new);
|
||||
|
||||
/// Access Token 吊销黑名单(token_hash -> 过期时间戳)
|
||||
/// key = SHA-256(token) 前 16 字符,value = token 的 exp 时间戳
|
||||
/// 惰性清理:检查时自动移除过期条目
|
||||
static TOKEN_BLACKLIST: std::sync::LazyLock<DashMap<String, i64>> =
|
||||
std::sync::LazyLock::new(DashMap::new);
|
||||
|
||||
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// 吊销单个 access token(直到其自然过期)
|
||||
pub fn revoke_access_token(token: &str, exp: i64) {
|
||||
let hash = token_hash(token);
|
||||
TOKEN_BLACKLIST.insert(hash, exp);
|
||||
}
|
||||
|
||||
/// 吊销用户所有 token(清除权限缓存,强制下次请求重新认证)
|
||||
pub fn revoke_all_user_tokens(user_id: uuid::Uuid) {
|
||||
USER_SCOPE_CACHE.remove(&user_id);
|
||||
}
|
||||
|
||||
/// 检查 token 是否已被吊销
|
||||
fn is_token_revoked(token: &str, _exp: i64) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
// 惰性清理过期条目
|
||||
if TOKEN_BLACKLIST.len() > 10_000 {
|
||||
TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now);
|
||||
}
|
||||
let hash = token_hash(token);
|
||||
match TOKEN_BLACKLIST.get(&hash) {
|
||||
Some(exp_ts) => {
|
||||
if *exp_ts <= now {
|
||||
drop(exp_ts);
|
||||
TOKEN_BLACKLIST.remove(&hash);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn token_hash(token: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
token.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
///
|
||||
/// Extracts the `Bearer` token from the `Authorization` header, validates it
|
||||
@@ -71,6 +117,11 @@ pub async fn jwt_auth_middleware_fn(
|
||||
let claims =
|
||||
TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
// 检查 token 是否已被吊销(密码修改/管理员强制下线)
|
||||
if is_token_revoked(&token, claims.exp) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// Verify this is an access token, not a refresh token
|
||||
if claims.token_type != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod jwt_auth;
|
||||
|
||||
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||
pub use jwt_auth::jwt_auth_middleware_fn;
|
||||
pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens};
|
||||
|
||||
@@ -23,12 +23,23 @@ impl AuthModule {
|
||||
/// These routes do not require a valid JWT token.
|
||||
/// The caller wraps this into whatever state type the application uses.
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new().route("/auth/login", axum::routing::post(auth_handler::login))
|
||||
}
|
||||
|
||||
/// WeChat public routes — separate from login to allow higher rate limits.
|
||||
///
|
||||
/// Mobile users may retry more frequently, so these use 30 req/min
|
||||
/// instead of the strict 5 req/min for password login.
|
||||
pub fn wechat_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||
.route(
|
||||
"/auth/wechat/login",
|
||||
axum::routing::post(wechat_handler::wechat_login),
|
||||
|
||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
use crate::dto::{LoginResp, RoleResp, UserResp};
|
||||
use crate::entity::{role, user, user_credential, user_role};
|
||||
use crate::error::AuthError;
|
||||
use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::EventBus;
|
||||
@@ -284,6 +285,9 @@ impl AuthService {
|
||||
) -> AuthResult<()> {
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 清除 access token 权限缓存,强制重新认证
|
||||
revoke_access_token_cache(user_id);
|
||||
|
||||
// 审计:登出
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "user.logout", "user")
|
||||
@@ -351,6 +355,9 @@ impl AuthService {
|
||||
// 4. Revoke all refresh tokens — force re-login on all devices
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效
|
||||
revoke_access_token_cache(user_id);
|
||||
|
||||
// 审计:密码修改
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user")
|
||||
|
||||
@@ -144,10 +144,15 @@ impl UserService {
|
||||
///
|
||||
/// Returns `(users, total_count)`. When `search` is provided, filters
|
||||
/// by username using case-insensitive substring match.
|
||||
///
|
||||
/// When `exclude_only_roles` is provided, users whose *only* role is one
|
||||
/// of the listed role codes are excluded (e.g. `["patient"]` hides
|
||||
/// patient-only users from the staff management page).
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
search: Option<&str>,
|
||||
exclude_only_roles: Option<&[String]>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<(Vec<UserResp>, u64)> {
|
||||
let mut query = user::Entity::find()
|
||||
@@ -161,6 +166,56 @@ impl UserService {
|
||||
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
|
||||
}
|
||||
|
||||
// Exclude users whose only role is one of the excluded role codes.
|
||||
// Two-step approach: first find user_ids that have ONLY excluded roles
|
||||
// via raw SQL, then exclude them from the main query.
|
||||
if let Some(roles) = exclude_only_roles
|
||||
&& !roles.is_empty()
|
||||
{
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
|
||||
let codes: Vec<String> = roles
|
||||
.iter()
|
||||
.map(|r| format!("'{}'", r.replace('\'', "''")))
|
||||
.collect();
|
||||
let codes_csv = codes.join(",");
|
||||
|
||||
// Find user_ids whose ONLY roles are in the excluded list.
|
||||
// A user qualifies if:
|
||||
// - they have at least one role in the excluded list
|
||||
// - they have ZERO roles outside the excluded list
|
||||
let excluded: Vec<Uuid> = db
|
||||
.query_all(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
format!(
|
||||
r#"SELECT u.id FROM users u
|
||||
WHERE u.tenant_id = $1 AND u.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL
|
||||
WHERE ur.user_id = u.id AND ur.tenant_id = $1
|
||||
AND r.code IN ({codes_csv})
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL
|
||||
WHERE ur.user_id = u.id AND ur.tenant_id = $1
|
||||
AND r.code NOT IN ({codes_csv})
|
||||
)"#
|
||||
),
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get("", "id").ok())
|
||||
.collect();
|
||||
|
||||
if !excluded.is_empty() {
|
||||
query = query.filter(user::Column::Id.is_not_in(excluded));
|
||||
}
|
||||
}
|
||||
|
||||
let paginator = query.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
|
||||
@@ -9,6 +9,66 @@ use sha2::{Digest, Sha256};
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 审计日志中需要脱敏的 PII 字段名(小写匹配)
|
||||
const PII_FIELDS: &[&str] = &[
|
||||
"id_number",
|
||||
"phone",
|
||||
"emergency_contact_phone",
|
||||
"emergency_contact_name",
|
||||
"allergy_history",
|
||||
"medical_history_summary",
|
||||
"name",
|
||||
"content",
|
||||
];
|
||||
|
||||
/// 审计日志中需要脱敏的 resource_type 前缀
|
||||
const PII_RESOURCE_TYPES: &[&str] = &[
|
||||
"patient",
|
||||
"consultation",
|
||||
"follow_up",
|
||||
"family_member",
|
||||
"doctor_profile",
|
||||
];
|
||||
|
||||
/// 对 JSON Value 中的 PII 字段进行脱敏
|
||||
fn sanitize_audit_value(
|
||||
value: &Option<serde_json::Value>,
|
||||
resource_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let needs_sanitization = PII_RESOURCE_TYPES
|
||||
.iter()
|
||||
.any(|prefix| resource_type.starts_with(prefix));
|
||||
|
||||
if !needs_sanitization {
|
||||
return value.clone();
|
||||
}
|
||||
|
||||
value.as_ref().map(sanitize_json_value)
|
||||
}
|
||||
|
||||
fn sanitize_json_value(v: &serde_json::Value) -> serde_json::Value {
|
||||
match v {
|
||||
serde_json::Value::Object(map) => {
|
||||
let sanitized: serde_json::Map<String, serde_json::Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let key_lower = k.to_lowercase();
|
||||
if PII_FIELDS.iter().any(|f| key_lower.contains(f)) {
|
||||
(k.clone(), serde_json::Value::String("***".to_string()))
|
||||
} else {
|
||||
(k.clone(), sanitize_json_value(v))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Object(sanitized)
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
serde_json::Value::Array(arr.iter().map(sanitize_json_value).collect())
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
///
|
||||
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
|
||||
@@ -43,6 +103,10 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 计算当前记录的 record_hash
|
||||
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
|
||||
|
||||
// 脱敏处理:对 patient/consultation/follow_up 等资源类型的变更值中 PII 字段进行 mask
|
||||
let sanitized_old = sanitize_audit_value(&log.old_value, &log.resource_type);
|
||||
let sanitized_new = sanitize_audit_value(&log.new_value, &log.resource_type);
|
||||
|
||||
// 保存日志字段用于错误日志(model 构建会 move String 字段)
|
||||
let err_tenant_id = log.tenant_id;
|
||||
let err_action = log.action.clone();
|
||||
@@ -56,8 +120,8 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
action: Set(log.action),
|
||||
resource_type: Set(log.resource_type),
|
||||
resource_id: Set(log.resource_id),
|
||||
old_value: Set(log.old_value),
|
||||
new_value: Set(log.new_value),
|
||||
old_value: Set(sanitized_old),
|
||||
new_value: Set(sanitized_new),
|
||||
ip_address: Set(log.ip_address),
|
||||
user_agent: Set(log.user_agent),
|
||||
created_at: Set(log.created_at),
|
||||
|
||||
66
crates/erp-health/src/dto/export_dto.rs
Normal file
66
crates/erp-health/src/dto/export_dto.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! 患者数据导出 DTO(个保法 §45 数据可携权)
|
||||
//!
|
||||
//! 双格式分工:
|
||||
//! - `json` — 自定义 JSON,PII 明文(可携权本意,患者拿到完整数据)
|
||||
//! - `fhir` — FHIR R4 Bundle,复用现有 converter,PII 天然脱敏(标准化互操作)
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::IntoParams;
|
||||
|
||||
/// 导出格式
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, utoipa::ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExportFormat {
|
||||
/// 自定义 JSON(明文 PII,可携权本意)
|
||||
#[default]
|
||||
Json,
|
||||
/// FHIR R4 Bundle(标准化互操作,PII 脱敏)
|
||||
Fhir,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Json => "json",
|
||||
Self::Fhir => "fhir",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ExportQuery {
|
||||
/// 导出格式:json(默认,明文)/ fhir(标准化 Bundle)
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportQuery {
|
||||
/// 解析 format 参数,未知值/缺省回退到 json
|
||||
pub fn parse_format(&self) -> ExportFormat {
|
||||
match self
|
||||
.format
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("fhir") => ExportFormat::Fhir,
|
||||
_ => ExportFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出响应
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct ExportResp {
|
||||
/// 导出格式
|
||||
pub format: ExportFormat,
|
||||
/// 导出时间
|
||||
pub exported_at: DateTime<Utc>,
|
||||
/// 各资源类型数量统计(如 `{"observations":12,"appointments":3}`)
|
||||
pub resource_counts: serde_json::Value,
|
||||
/// 是否因 limit 截断(MVP 同步导出,大数据量时为 true)
|
||||
pub truncated: bool,
|
||||
/// 导出载荷(JSON 明文结构 或 FHIR Bundle)
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod consultation_dto;
|
||||
pub mod daily_monitoring_dto;
|
||||
pub mod diagnosis_dto;
|
||||
pub mod doctor_dto;
|
||||
pub mod export_dto;
|
||||
pub mod follow_up_dto;
|
||||
pub mod follow_up_template_dto;
|
||||
pub mod health_data_dto;
|
||||
|
||||
@@ -64,6 +64,8 @@ pub const PATIENT_UPDATED: &str = "patient.updated";
|
||||
// TODO: 以下常量对应的患者认证和死亡记录流程尚未实现,待后续迭代
|
||||
pub const PATIENT_VERIFIED: &str = "patient.verified";
|
||||
pub const PATIENT_DECEASED: &str = "patient.deceased";
|
||||
/// 患者数据导出(个保法 §45 数据可携权)— 审计 + 后续可触发导出完成通知
|
||||
pub const PATIENT_EXPORTED: &str = "patient.exported";
|
||||
|
||||
// 积分
|
||||
pub const POINTS_EXPIRED: &str = "points.expired";
|
||||
|
||||
@@ -10,11 +10,13 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::export_dto::{ExportQuery, ExportResp};
|
||||
use crate::dto::patient_dto::{
|
||||
BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq,
|
||||
FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, PatientSummary, ReferPatientReq,
|
||||
ReferResultResp, UpdatePatientReq,
|
||||
};
|
||||
use crate::handler::consent_check::check_consent_active;
|
||||
use crate::service::patient_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -24,6 +26,9 @@ pub struct PatientListParams {
|
||||
pub page_size: Option<u64>,
|
||||
pub search: Option<String>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
/// Optional user_id filter — only return patients linked to this user.
|
||||
/// Used by the mini-program to fetch only the logged-in user's own patients.
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 分配医生请求
|
||||
@@ -70,7 +75,9 @@ where
|
||||
require_permission(&ctx, "health.patient.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||
let result = patient_service::list_summaries(&state, ctx.tenant_id, page, page_size).await?;
|
||||
let result =
|
||||
patient_service::list_summaries(&state, ctx.tenant_id, page, page_size, params.user_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -577,3 +584,50 @@ where
|
||||
patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
/// 患者数据导出(个保法 §45 数据可携权)
|
||||
///
|
||||
/// 双格式:`json`(明文 PII,可携本意)/ `fhir`(标准化 Bundle,脱敏)。
|
||||
/// 强制 consent 门控 + patient 角色 self-scope(仅导出自己 user_id 关联的档案)。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/patients/{id}/export",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "患者 ID"),
|
||||
ExportQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "导出成功", body = ExportResp),
|
||||
(status = 403, description = "无权限 / 仅能导出自己的数据 / 知情同意未授权"),
|
||||
(status = 404, description = "患者不存在"),
|
||||
),
|
||||
tag = "患者管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn export_patient<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
) -> Result<Json<ApiResponse<ExportResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.export")?;
|
||||
check_consent_active(&state.db, ctx.tenant_id, id, &ctx).await?;
|
||||
|
||||
// patient 角色 self-scope:只能导出自己 user_id 关联的档案。
|
||||
// 用 get_patient_user_id 轻量查询,避免校验阶段解密他人 PII。
|
||||
if ctx.roles.iter().any(|r| r == "patient") {
|
||||
let owner = patient_service::get_patient_user_id(&state, ctx.tenant_id, id).await?;
|
||||
if owner != Some(ctx.user_id) {
|
||||
return Err(AppError::Forbidden("只能导出自己的数据".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let fmt = query.parse_format();
|
||||
let resp =
|
||||
patient_service::export_patient(&state, ctx.tenant_id, Some(ctx.user_id), id, fmt).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ where
|
||||
"/health/patients/{id}/refer",
|
||||
axum::routing::post(patient_handler::refer_patient),
|
||||
)
|
||||
// 患者数据导出(个保法 §45 数据可携权)
|
||||
.route(
|
||||
"/health/patients/{id}/export",
|
||||
axum::routing::get(patient_handler::export_patient),
|
||||
)
|
||||
// 家庭成员健康代理 — 管理端
|
||||
.route(
|
||||
"/health/patients/{patient_id}/family-members/{family_member_id}/grant-access",
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
//! 数据脱敏和状态转换验证
|
||||
//!
|
||||
//! 脱敏函数统一使用 erp_core::crypto 中的实现(Unicode 安全版本)。
|
||||
//! 此模块仅保留 health 业务特有的 validate_status_transition。
|
||||
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
|
||||
/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
|
||||
pub fn mask_id_number(s: &str) -> String {
|
||||
if s.len() >= 7 {
|
||||
format!("{}****{}", &s[..3], &s[s.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
|
||||
pub fn mask_phone(s: Option<&str>) -> Option<String> {
|
||||
s.map(|p| {
|
||||
if p.len() >= 7 {
|
||||
format!("{}****{}", &p[..3], &p[p.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
// 重导出 erp-core 的脱敏函数,供 health 模块内部统一引用
|
||||
pub use erp_core::crypto::{mask_id_number, mask_phone};
|
||||
|
||||
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
|
||||
pub fn validate_status_transition(
|
||||
@@ -54,16 +40,6 @@ mod tests {
|
||||
assert_eq!("110****1234", mask_id_number("110101199001011234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_15_digits() {
|
||||
assert_eq!("123****2345", mask_id_number("123456789012345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_7_chars() {
|
||||
assert_eq!("123****4567", mask_id_number("1234567"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_short() {
|
||||
assert_eq!("****", mask_id_number("123456"));
|
||||
@@ -82,16 +58,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_7_chars() {
|
||||
assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_short() {
|
||||
assert_eq!(Some("****".to_string()), mask_phone(Some("123456")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_none() {
|
||||
assert_eq!(None, mask_phone(None));
|
||||
|
||||
@@ -254,6 +254,19 @@ pub async fn get_patient(
|
||||
Ok(model_to_resp_decrypted(&state.crypto, model))
|
||||
}
|
||||
|
||||
/// 查询患者关联的 user_id(仅用于权限 self-scope 校验,不解密 PII)
|
||||
///
|
||||
/// 个保法 §45 导出场景:patient 角色只能导出自己 user_id 关联的档案。
|
||||
/// 用此轻量查询避免在校验阶段解密他人 PII(仅读 user_id 非敏感字段)。
|
||||
pub async fn get_patient_user_id(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> HealthResult<Option<Uuid>> {
|
||||
let model = find_patient(&state.db, tenant_id, id).await?;
|
||||
Ok(model.user_id)
|
||||
}
|
||||
|
||||
/// 更新患者信息(乐观锁)
|
||||
pub async fn update_patient(
|
||||
state: &HealthState,
|
||||
@@ -552,19 +565,27 @@ pub async fn bind_by_phone(
|
||||
}
|
||||
|
||||
/// 患者摘要列表 — 仅返回非敏感字段,供小程序切换/列表使用
|
||||
///
|
||||
/// When `user_id` is provided, only patients linked to that user are returned.
|
||||
/// This allows the mini-program to fetch only the logged-in user's own patients.
|
||||
pub async fn list_summaries(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<PaginatedResponse<PatientSummary>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let query = patient::Entity::find()
|
||||
let mut query = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(uid) = user_id {
|
||||
query = query.filter(patient::Column::UserId.eq(uid));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
|
||||
let models = query
|
||||
|
||||
278
crates/erp-health/src/service/patient_service/export.rs
Normal file
278
crates/erp-health/src/service/patient_service/export.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! 患者数据导出 Service(个保法 §45 数据可携权)
|
||||
//!
|
||||
//! 双格式:
|
||||
//! - `json` — 自定义 JSON,PII 明文(可携权本意,患者拿到完整数据)
|
||||
//! - `fhir` — FHIR R4 Bundle,复用现有 converter,PII 天然脱敏
|
||||
//!
|
||||
//! 数据装配逻辑复刻 `fhir::patient_everything`(7 段查询 + 相同 limit),
|
||||
//! 但走 `/health/` JWT 体系(非 `/fhir` OAuth),并补明文 JSON 格式。
|
||||
//! 强制审计 `patient.exported`(new_value 只含 format/counts/标记位,绝不落明文 PII)。
|
||||
|
||||
use chrono::Utc;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::DomainEvent;
|
||||
use sea_orm::QuerySelect;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::export_dto::{ExportFormat, ExportResp};
|
||||
use crate::entity::{
|
||||
appointment, consultation_session, device_readings, follow_up_task, lab_report, patient,
|
||||
patient_devices,
|
||||
};
|
||||
use crate::error::HealthResult;
|
||||
use crate::fhir::converter;
|
||||
use crate::state::HealthState;
|
||||
|
||||
use super::helper::{find_patient, patient_plaintext_pii};
|
||||
|
||||
/// 导出限制(与 fhir::patient_everything 对齐,避免大数据量阻塞同步响应)
|
||||
const OBSERVATIONS_LIMIT: u64 = 200;
|
||||
const TASKS_LIMIT: u64 = 50;
|
||||
const REPORTS_LIMIT: u64 = 50;
|
||||
|
||||
/// 装配的患者数据(原始 Model 向量,两格式共享同一批查询)
|
||||
struct AssembledData {
|
||||
patient: patient::Model,
|
||||
readings: Vec<device_readings::Model>,
|
||||
devices: Vec<patient_devices::Model>,
|
||||
consultations: Vec<consultation_session::Model>,
|
||||
appointments: Vec<appointment::Model>,
|
||||
tasks: Vec<follow_up_task::Model>,
|
||||
reports: Vec<lab_report::Model>,
|
||||
}
|
||||
|
||||
/// 导出患者数据(个保法 §45 数据可携权)
|
||||
///
|
||||
/// 强制审计 `patient.exported`(不含明文 PII),并发事件 `patient.exported`。
|
||||
/// 日志只记元数据(patient_id/format/counts),绝不记录 payload。
|
||||
pub async fn export_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
patient_id: Uuid,
|
||||
format: ExportFormat,
|
||||
) -> HealthResult<ExportResp> {
|
||||
tracing::info!(
|
||||
action = "export_patient",
|
||||
%patient_id,
|
||||
%tenant_id,
|
||||
format = format.as_str(),
|
||||
"Exporting patient data (PIPL §45)"
|
||||
);
|
||||
|
||||
let data = assemble(state, tenant_id, patient_id).await?;
|
||||
let truncated = data.readings.len() as u64 >= OBSERVATIONS_LIMIT
|
||||
|| data.tasks.len() as u64 >= TASKS_LIMIT
|
||||
|| data.reports.len() as u64 >= REPORTS_LIMIT;
|
||||
|
||||
let (payload, counts) = match format {
|
||||
ExportFormat::Json => build_json_payload(state, &data),
|
||||
ExportFormat::Fhir => (build_fhir_bundle(&data), build_counts(&data)),
|
||||
};
|
||||
|
||||
let exported_at = Utc::now();
|
||||
|
||||
// 审计:只记录动作元数据,绝不落明文 PII。
|
||||
// json 格式 contains_plaintext_pii=true 标记响应含明文,便于事后追溯。
|
||||
let audit_value = serde_json::json!({
|
||||
"format": format.as_str(),
|
||||
"resource_counts": counts.clone(),
|
||||
"contains_plaintext_pii": format == ExportFormat::Json,
|
||||
});
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.exported", "patient")
|
||||
.with_resource_id(patient_id)
|
||||
.with_changes(None, Some(audit_value)),
|
||||
&state.db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 事件(现有 event/patient.rs 订阅器对 exported 是 no-op,无副作用;留作后续触发通知)
|
||||
let event = DomainEvent::new(
|
||||
crate::event::PATIENT_EXPORTED,
|
||||
tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"patient_id": patient_id,
|
||||
"format": format.as_str(),
|
||||
})),
|
||||
);
|
||||
state.event_bus.publish(event, &state.db).await;
|
||||
|
||||
tracing::info!(
|
||||
action = "export_patient",
|
||||
%patient_id,
|
||||
format = format.as_str(),
|
||||
truncated,
|
||||
"Patient export completed"
|
||||
);
|
||||
|
||||
Ok(ExportResp {
|
||||
format,
|
||||
exported_at,
|
||||
resource_counts: counts,
|
||||
truncated,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
/// 装配患者全量数据(7 段查询,复刻 fhir::patient_everything 装配逻辑)
|
||||
async fn assemble(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> HealthResult<AssembledData> {
|
||||
let patient = find_patient(&state.db, tenant_id, patient_id).await?;
|
||||
|
||||
let readings = device_readings::Entity::find()
|
||||
.filter(device_readings::Column::PatientId.eq(patient_id))
|
||||
.filter(device_readings::Column::TenantId.eq(tenant_id))
|
||||
.filter(device_readings::Column::DeletedAt.is_null())
|
||||
.limit(OBSERVATIONS_LIMIT)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let devices = patient_devices::Entity::find()
|
||||
.filter(patient_devices::Column::PatientId.eq(patient_id))
|
||||
.filter(patient_devices::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient_devices::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let consultations = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::PatientId.eq(patient_id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let appointments = appointment::Entity::find()
|
||||
.filter(appointment::Column::PatientId.eq(patient_id))
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let tasks = follow_up_task::Entity::find()
|
||||
.filter(follow_up_task::Column::PatientId.eq(patient_id))
|
||||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||||
.limit(TASKS_LIMIT)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let reports = lab_report::Entity::find()
|
||||
.filter(lab_report::Column::PatientId.eq(patient_id))
|
||||
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||
.filter(lab_report::Column::DeletedAt.is_null())
|
||||
.limit(REPORTS_LIMIT)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(AssembledData {
|
||||
patient,
|
||||
readings,
|
||||
devices,
|
||||
consultations,
|
||||
appointments,
|
||||
tasks,
|
||||
reports,
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建 JSON 明文载荷(解密 PII 不脱敏)+ counts
|
||||
fn build_json_payload(
|
||||
state: &HealthState,
|
||||
data: &AssembledData,
|
||||
) -> (serde_json::Value, serde_json::Value) {
|
||||
let pii = patient_plaintext_pii(&state.crypto, &data.patient);
|
||||
|
||||
// data 是引用,非 Copy 字段(String/Option<String>/Vec)须用 & 避免 move out of borrow
|
||||
let patient_json = serde_json::json!({
|
||||
"id": data.patient.id,
|
||||
"tenant_id": data.patient.tenant_id,
|
||||
"user_id": data.patient.user_id,
|
||||
"name": &data.patient.name,
|
||||
"gender": &data.patient.gender,
|
||||
"birth_date": data.patient.birth_date,
|
||||
"blood_type": &data.patient.blood_type,
|
||||
"id_number": &pii.id_number,
|
||||
"allergy_history": &pii.allergy_history,
|
||||
"medical_history_summary": &pii.medical_history_summary,
|
||||
"emergency_contact_name": &data.patient.emergency_contact_name,
|
||||
"emergency_contact_phone": &pii.emergency_contact_phone,
|
||||
"status": &data.patient.status,
|
||||
"verification_status": &data.patient.verification_status,
|
||||
"source": &data.patient.source,
|
||||
"notes": &data.patient.notes,
|
||||
"created_at": data.patient.created_at,
|
||||
"updated_at": data.patient.updated_at,
|
||||
});
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"export": {
|
||||
"legal_basis": "PIPL §45 数据可携权",
|
||||
"format": "json",
|
||||
"note": "包含明文个人身份信息(PII),请妥善保管",
|
||||
},
|
||||
"patient": patient_json,
|
||||
"device_readings": &data.readings,
|
||||
"patient_devices": &data.devices,
|
||||
"consultation_sessions": &data.consultations,
|
||||
"appointments": &data.appointments,
|
||||
"follow_up_tasks": &data.tasks,
|
||||
"lab_reports": &data.reports,
|
||||
});
|
||||
|
||||
(payload, build_counts(data))
|
||||
}
|
||||
|
||||
/// 构建 FHIR R4 Bundle(复用 converter,PII 天然脱敏)
|
||||
fn build_fhir_bundle(data: &AssembledData) -> serde_json::Value {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.push(serde_json::json!({
|
||||
"resource": converter::patient_to_fhir(&data.patient),
|
||||
"fullUrl": format!("https://hms.local/fhir/R4/Patient/{}", data.patient.id),
|
||||
}));
|
||||
for r in &data.readings {
|
||||
for obs in converter::device_reading_to_fhir_observations(r) {
|
||||
entries.push(serde_json::json!({ "resource": obs }));
|
||||
}
|
||||
}
|
||||
for d in &data.devices {
|
||||
entries.push(serde_json::json!({ "resource": converter::patient_device_to_fhir(d) }));
|
||||
}
|
||||
for c in &data.consultations {
|
||||
entries.push(serde_json::json!({ "resource": converter::consultation_to_fhir(c) }));
|
||||
}
|
||||
for a in &data.appointments {
|
||||
entries.push(serde_json::json!({ "resource": converter::appointment_to_fhir(a) }));
|
||||
}
|
||||
for t in &data.tasks {
|
||||
entries.push(serde_json::json!({ "resource": converter::follow_up_to_fhir(t) }));
|
||||
}
|
||||
for r in &data.reports {
|
||||
entries.push(serde_json::json!({ "resource": converter::lab_report_to_fhir(r) }));
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"resourceType": "Bundle",
|
||||
"type": "collection",
|
||||
"total": entries.len(),
|
||||
"entry": entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// 各资源数量统计
|
||||
fn build_counts(data: &AssembledData) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"observations": data.readings.len(),
|
||||
"devices": data.devices.len(),
|
||||
"encounters": data.consultations.len(),
|
||||
"appointments": data.appointments.len(),
|
||||
"tasks": data.tasks.len(),
|
||||
"diagnostic_reports": data.reports.len(),
|
||||
})
|
||||
}
|
||||
@@ -92,8 +92,40 @@ pub(crate) fn model_to_resp_decrypted(crypto: &PiiCrypto, m: patient::Model) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// 患者明文 PII(仅用于数据导出 §45 可携权,不脱敏)
|
||||
///
|
||||
/// 与 `model_to_resp_decrypted` 区别:后者对 id_number/phone 脱敏用于日常展示;
|
||||
/// 本结构返回原始明文,专供患者数据可携权导出(个保法 §45)。
|
||||
pub(crate) struct PatientPlaintextPii {
|
||||
pub id_number: Option<String>,
|
||||
pub allergy_history: Option<String>,
|
||||
pub medical_history_summary: Option<String>,
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
}
|
||||
|
||||
/// 解密患者全部 PII 字段(不脱敏),供数据导出使用
|
||||
pub(crate) fn patient_plaintext_pii(crypto: &PiiCrypto, m: &patient::Model) -> PatientPlaintextPii {
|
||||
let kek = crypto.kek();
|
||||
PatientPlaintextPii {
|
||||
id_number: decrypt_field(kek, &m.id_number, "id_number", m.id),
|
||||
allergy_history: decrypt_field(kek, &m.allergy_history, "allergy_history", m.id),
|
||||
medical_history_summary: decrypt_field(
|
||||
kek,
|
||||
&m.medical_history_summary,
|
||||
"medical_history_summary",
|
||||
m.id,
|
||||
),
|
||||
emergency_contact_phone: decrypt_field(
|
||||
kek,
|
||||
&m.emergency_contact_phone,
|
||||
"emergency_contact_phone",
|
||||
m.id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密单个 PII 字段,失败时输出 warn 日志并返回 None
|
||||
fn decrypt_field(
|
||||
pub(crate) fn decrypt_field(
|
||||
kek: &[u8; 32],
|
||||
field: &Option<String>,
|
||||
name: &str,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要
|
||||
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要、数据导出
|
||||
//!
|
||||
//! 按 4 个功能域组织:
|
||||
//! 按 5 个功能域组织:
|
||||
//! - `crud` — 患者基础 CRUD 操作
|
||||
//! - `export` — 患者数据导出(个保法 §45 数据可携权)
|
||||
//! - `relation` — 家庭成员、医生关联、标签管理(患者关联)、健康摘要
|
||||
//! - `tag` — 患者标签 CRUD
|
||||
//! - `helper` — 共享辅助函数
|
||||
|
||||
mod crud;
|
||||
mod export;
|
||||
mod helper;
|
||||
mod relation;
|
||||
mod tag;
|
||||
@@ -14,8 +16,9 @@ mod tag;
|
||||
// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变
|
||||
pub use crud::{
|
||||
batch_import_patients, bind_by_phone, create_patient, delete_patient, get_patient,
|
||||
list_patients, list_summaries, update_patient,
|
||||
get_patient_user_id, list_patients, list_summaries, update_patient,
|
||||
};
|
||||
pub use export::export_patient;
|
||||
pub use relation::{
|
||||
assign_doctor, create_family_member, delete_family_member, get_health_summary,
|
||||
list_family_members, manage_patient_tags, refer_patient, remove_doctor, update_family_member,
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
//! 统计 Service — 工作台管理统计
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sea_orm::{ConnectionTrait, FromQueryResult};
|
||||
use tokio::try_join;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::state::HealthState;
|
||||
|
||||
/// 文章状态统计
|
||||
// ---------------------------------------------------------------------------
|
||||
// 健康检测结果缓存(30s TTL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static HEALTH_CACHE: std::sync::OnceLock<Mutex<Option<(Instant, SystemHealthResp)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn get_health_cache() -> &'static Mutex<Option<(Instant, SystemHealthResp)>> {
|
||||
HEALTH_CACHE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
const HEALTH_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 文章统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_article_stats(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
@@ -61,7 +82,10 @@ pub async fn get_article_stats(
|
||||
})
|
||||
}
|
||||
|
||||
/// 积分最近动态
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分最近动态
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_points_recent_activity(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
@@ -113,7 +137,10 @@ pub async fn get_points_recent_activity(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 模块状态
|
||||
// ---------------------------------------------------------------------------
|
||||
// 模块状态(entity_count 校正为实际值)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStatusResp>> {
|
||||
let modules = vec![
|
||||
ModuleStatusResp {
|
||||
@@ -121,7 +148,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "身份权限".into(),
|
||||
description: "用户/角色/权限/组织/部门".into(),
|
||||
active: true,
|
||||
entity_count: Some(9),
|
||||
entity_count: Some(13),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -129,7 +156,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "系统配置".into(),
|
||||
description: "字典/菜单/设置/编号规则".into(),
|
||||
active: true,
|
||||
entity_count: Some(6),
|
||||
entity_count: Some(7),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -137,7 +164,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "工作流引擎".into(),
|
||||
description: "BPMN 解析/任务分配".into(),
|
||||
active: true,
|
||||
entity_count: Some(5),
|
||||
entity_count: Some(6),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -145,7 +172,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "消息中心".into(),
|
||||
description: "消息/模板/订阅/通知".into(),
|
||||
active: true,
|
||||
entity_count: Some(3),
|
||||
entity_count: Some(4),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -153,7 +180,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "健康管理".into(),
|
||||
description: "患者/体征/预约/随访/咨询".into(),
|
||||
active: true,
|
||||
entity_count: Some(45),
|
||||
entity_count: Some(59),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -161,7 +188,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "AI 分析".into(),
|
||||
description: "智能分析/化验解读/趋势".into(),
|
||||
active: true,
|
||||
entity_count: Some(3),
|
||||
entity_count: Some(24),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -169,7 +196,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "透析管理".into(),
|
||||
description: "透析记录/处方/用药".into(),
|
||||
active: true,
|
||||
entity_count: Some(5),
|
||||
entity_count: Some(3),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -177,7 +204,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "插件系统".into(),
|
||||
description: "WASM 运行时/动态表".into(),
|
||||
active: true,
|
||||
entity_count: Some(4),
|
||||
entity_count: Some(6),
|
||||
route_count: None,
|
||||
},
|
||||
];
|
||||
@@ -185,16 +212,19 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// 用户活跃度统计
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户活跃度(基于 audit_log 真实操作记录)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_user_activity(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<UserActivityResp> {
|
||||
let sql = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '1 day') AS daily_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '30 days') AS monthly_active,
|
||||
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '1 day' AND user_id IS NOT NULL) AS daily_active,
|
||||
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '7 days' AND user_id IS NOT NULL) AS weekly_active,
|
||||
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '30 days' AND user_id IS NOT NULL) AS monthly_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered
|
||||
"#;
|
||||
|
||||
@@ -263,79 +293,237 @@ pub async fn get_user_activity(
|
||||
})
|
||||
}
|
||||
|
||||
/// 系统健康检查
|
||||
// ---------------------------------------------------------------------------
|
||||
// 系统健康检查(全部真实检测,30s 缓存)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_system_health(state: &HealthState) -> AppResult<SystemHealthResp> {
|
||||
let mut services = Vec::new();
|
||||
// 检查缓存
|
||||
{
|
||||
let cache = get_health_cache().lock().unwrap();
|
||||
if let Some((ts, resp)) = cache.as_ref()
|
||||
&& ts.elapsed() < HEALTH_CACHE_TTL
|
||||
{
|
||||
return Ok(resp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 数据库检查
|
||||
let db_start = std::time::Instant::now();
|
||||
let db_status = match state
|
||||
.db
|
||||
// 并行执行所有检测
|
||||
let db_fut = check_database(&state.db);
|
||||
let queue_fut = check_eventbus_backlog(&state.db);
|
||||
let storage_fut = check_file_storage();
|
||||
let cron_fut = check_cron_heartbeat(&state.cron_heartbeat);
|
||||
|
||||
let (db_status, queue_status, storage_status, cron_status) =
|
||||
try_join!(db_fut, queue_fut, storage_fut, cron_fut)?;
|
||||
|
||||
let total_ms = start.elapsed().as_millis() as i64;
|
||||
|
||||
let mut services = Vec::new();
|
||||
|
||||
// PostgreSQL
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "PostgreSQL".into(),
|
||||
status: db_status.status.clone(),
|
||||
message: db_status.message.clone(),
|
||||
response_ms: db_status.response_ms,
|
||||
});
|
||||
|
||||
// 外部注入的组件检测(Redis 等,由 erp-server 提供)
|
||||
for (name, check_fn) in &state.external_health_checks {
|
||||
let result = check_fn().await;
|
||||
services.push(ServiceHealthStatus {
|
||||
name: (*name).into(),
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
response_ms: result.response_ms,
|
||||
});
|
||||
}
|
||||
|
||||
// 消息队列
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "消息队列".into(),
|
||||
status: queue_status.status.clone(),
|
||||
message: queue_status.message.clone(),
|
||||
response_ms: queue_status.response_ms,
|
||||
});
|
||||
|
||||
// 文件存储
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "文件存储".into(),
|
||||
status: storage_status.status.clone(),
|
||||
message: storage_status.message.clone(),
|
||||
response_ms: storage_status.response_ms,
|
||||
});
|
||||
|
||||
// 定时任务
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "定时任务".into(),
|
||||
status: cron_status.status.clone(),
|
||||
message: cron_status.message.clone(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
// API 服务(自身响应时间 = 最可靠的 API 健康指标)
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "API 服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: format!("运行中 (检测耗时 {total_ms}ms)"),
|
||||
response_ms: Some(total_ms),
|
||||
});
|
||||
|
||||
let resp = SystemHealthResp {
|
||||
services,
|
||||
checked_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
// 更新缓存
|
||||
{
|
||||
let mut cache = get_health_cache().lock().unwrap();
|
||||
*cache = Some((Instant::now(), resp.clone()));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 各组件真实检测
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CheckResult {
|
||||
status: String,
|
||||
message: String,
|
||||
response_ms: Option<i64>,
|
||||
}
|
||||
|
||||
async fn check_database(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
|
||||
let t = std::time::Instant::now();
|
||||
let result = db
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT 1".to_string(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => "healthy".to_string(),
|
||||
Err(e) => format!("down: {e}"),
|
||||
};
|
||||
let db_ms = db_start.elapsed().as_millis() as i64;
|
||||
.await;
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "PostgreSQL".into(),
|
||||
status: if db_status == "healthy" {
|
||||
"healthy".into()
|
||||
} else {
|
||||
"down".into()
|
||||
let ms = t.elapsed().as_millis() as i64;
|
||||
Ok(match result {
|
||||
Ok(_) => CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: format!("正常 ({ms}ms)"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
message: if db_status == "healthy" {
|
||||
"正常".into()
|
||||
} else {
|
||||
db_status
|
||||
Err(e) => CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("不可用: {e}"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
response_ms: Some(db_ms),
|
||||
});
|
||||
|
||||
// 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy)
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "API 服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "运行中".into(),
|
||||
response_ms: Some(start.elapsed().as_millis() as i64),
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "定时任务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "正常运行".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "文件存储".into(),
|
||||
status: "healthy".into(),
|
||||
message: "可用".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "消息队列".into(),
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "缓存服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "正常".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
Ok(SystemHealthResp {
|
||||
services,
|
||||
checked_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_eventbus_backlog(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
|
||||
let t = std::time::Instant::now();
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountRow {
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let sql = "SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'";
|
||||
let result: Result<Option<CountRow>, _> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
|
||||
)
|
||||
.one(db)
|
||||
.await;
|
||||
|
||||
let ms = t.elapsed().as_millis() as i64;
|
||||
|
||||
Ok(match result {
|
||||
Ok(Some(row)) => match row.cnt {
|
||||
0 => CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
n if n <= 100 => CheckResult {
|
||||
status: "degraded".into(),
|
||||
message: format!("{n} 条待处理"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
n => CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("积压严重: {n} 条"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
},
|
||||
Ok(None) => CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
Err(e) => CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("查询失败: {e}"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_file_storage() -> AppResult<CheckResult> {
|
||||
let upload_dir = std::path::Path::new("uploads");
|
||||
if !upload_dir.exists() || !upload_dir.is_dir() {
|
||||
return Ok(CheckResult {
|
||||
status: "down".into(),
|
||||
message: "uploads/ 目录不存在".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
}
|
||||
|
||||
let test_path = upload_dir.join(".health_check_tmp");
|
||||
let t = std::time::Instant::now();
|
||||
match std::fs::write(&test_path, b"check") {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&test_path);
|
||||
let ms = t.elapsed().as_millis() as i64;
|
||||
Ok(CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: format!("可读写 ({ms}ms)"),
|
||||
response_ms: Some(ms),
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("不可写: {e}"),
|
||||
response_ms: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_cron_heartbeat(
|
||||
heartbeat: &std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
) -> AppResult<CheckResult> {
|
||||
let last_ts = heartbeat.load(Ordering::Relaxed);
|
||||
let now_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let elapsed_secs = now_ts.saturating_sub(last_ts);
|
||||
|
||||
// 阈值:最频繁的定时任务是 30s 一次的指标采样,设 5 分钟为容忍上限
|
||||
Ok(if elapsed_secs < 300 {
|
||||
CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: format!("正常 (上次心跳 {}s 前)", elapsed_secs),
|
||||
response_ms: None,
|
||||
}
|
||||
} else {
|
||||
let mins = elapsed_secs / 60;
|
||||
CheckResult {
|
||||
status: "degraded".into(),
|
||||
message: format!("超过 {mins} 分钟无心跳"),
|
||||
response_ms: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ mod m20260526_000164_ai_prompt_add_analysis_type;
|
||||
mod m20260526_000165_ai_prompt_fix_analysis_type;
|
||||
mod m20260526_000166_create_ai_knowledge_bases;
|
||||
mod m20260526_000167_create_ai_knowledge_documents;
|
||||
mod m20260527_000168_ai_knowledge_v2_menu;
|
||||
mod m20260529_000169_supplement_rls_for_new_tables;
|
||||
mod m20260626_000170_extend_device_readings_partitions;
|
||||
mod m20260626_000171_seed_patient_export_permission;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -355,6 +359,10 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
|
||||
Box::new(m20260526_000166_create_ai_knowledge_bases::Migration),
|
||||
Box::new(m20260526_000167_create_ai_knowledge_documents::Migration),
|
||||
Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration),
|
||||
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
|
||||
Box::new(m20260626_000170_extend_device_readings_partitions::Migration),
|
||||
Box::new(m20260626_000171_seed_patient_export_permission::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 将旧版 AI 知识库菜单更新为 V2 版本
|
||||
let sql = r#"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'sys_menu') THEN
|
||||
UPDATE sys_menu
|
||||
SET name = '知识库管理',
|
||||
icon = 'DatabaseOutlined',
|
||||
component = 'ai/KnowledgeV2Page',
|
||||
updated_at = now()
|
||||
WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"#;
|
||||
|
||||
manager.get_connection().execute_unprepared(sql).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'sys_menu') THEN
|
||||
UPDATE sys_menu
|
||||
SET name = 'AI 知识库',
|
||||
icon = 'BookOutlined',
|
||||
component = 'health/AiKnowledgePage',
|
||||
updated_at = now()
|
||||
WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// 为 m000088 之后创建的新表补充 RLS 策略。
|
||||
// 幂等操作:仅影响尚未启用 RLS 或缺少策略的表。
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
policy_exists BOOLEAN;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
-- 启用 RLS(幂等)
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
|
||||
|
||||
-- 检查是否已有 tenant_isolation 策略
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = tbl
|
||||
AND policyname = 'tenant_isolation'
|
||||
) INTO policy_exists;
|
||||
|
||||
IF NOT policy_exists THEN
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING (
|
||||
current_setting(''app.current_tenant_id'', true) != ''''
|
||||
AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
|
||||
)',
|
||||
tbl
|
||||
);
|
||||
RAISE NOTICE 'Created RLS policy for table: %', tbl;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 回滚不需要移除 RLS,保持 m000088 的策略不变
|
||||
// 此迁移补充的 RLS 策略在 down() 中保留,因为 m000088 已处理回滚
|
||||
let _ = manager;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
/// 补建 device_readings 分区到 2027_06。
|
||||
///
|
||||
/// 背景:m000073 只静态建了 2026_05~2026_08 四个分区,2026-09-01 起 INSERT 将因
|
||||
/// 无目标分区抛错,导致小程序 Veepoo M2 BLE 数据上传全线中断(确定性硬截止)。
|
||||
/// 本迁移补建 2026_09~2027_06 共 10 个月分区解除截止;中期应引入 pg_partman
|
||||
/// 或定时任务自动维护未来分区(见系统分析 PP-02)。
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 分区范围字面量为受控常量(非用户输入),与 m000073 写法一致
|
||||
let partitions: [(&str, &str, &str); 10] = [
|
||||
("2026_09", "2026-09-01", "2026-10-01"),
|
||||
("2026_10", "2026-10-01", "2026-11-01"),
|
||||
("2026_11", "2026-11-01", "2026-12-01"),
|
||||
("2026_12", "2026-12-01", "2027-01-01"),
|
||||
("2027_01", "2027-01-01", "2027-02-01"),
|
||||
("2027_02", "2027-02-01", "2027-03-01"),
|
||||
("2027_03", "2027-03-01", "2027-04-01"),
|
||||
("2027_04", "2027-04-01", "2027-05-01"),
|
||||
("2027_05", "2027-05-01", "2027-06-01"),
|
||||
("2027_06", "2027-06-01", "2027-07-01"),
|
||||
];
|
||||
for (suffix, start, end) in partitions {
|
||||
let sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS device_readings_{suffix} PARTITION OF device_readings FOR VALUES FROM ('{start}') TO ('{end}');"
|
||||
);
|
||||
manager.get_connection().execute_unprepared(&sql).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let suffixes = [
|
||||
"2026_09", "2026_10", "2026_11", "2026_12", "2027_01", "2027_02", "2027_03", "2027_04",
|
||||
"2027_05", "2027_06",
|
||||
];
|
||||
for suffix in suffixes {
|
||||
let sql = format!("DROP TABLE IF EXISTS device_readings_{suffix};");
|
||||
manager.get_connection().execute_unprepared(&sql).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 个保法 §45 数据可携权:注册 health.patient.export 权限并分配角色
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
let sys = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// 1) 注册 health.patient.export 权限(跨租户幂等)
|
||||
db.execute_unprepared(&format!(
|
||||
"INSERT INTO permissions (id, tenant_id, name, code, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT gen_random_uuid(), t.id, '患者数据导出(数据可携权)', 'health.patient.export', 'health', 'export', '个保法 §45 数据可携权:导出患者全量健康数据', NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \
|
||||
FROM tenant t \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.tenant_id = t.id AND p.code = 'health.patient.export' AND p.deleted_at IS NULL)"
|
||||
)).await?;
|
||||
|
||||
// 2) 医护和管理角色(data_scope=all):可导出任意患者数据
|
||||
let staff_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"];
|
||||
for role in staff_roles {
|
||||
assign_single_perm(db, role, "health.patient.export").await?;
|
||||
}
|
||||
|
||||
// 3) patient 角色(data_scope=self):仅导出自己的数据
|
||||
// handler 层 enforce self-scope:patient.user_id == ctx.user_id
|
||||
assign_perms_by_codes(db, "patient", &["health.patient.export"]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 移除所有角色的 health.patient.export 关联
|
||||
db.execute_unprepared(
|
||||
"DELETE FROM role_permissions \
|
||||
WHERE permission_id IN (SELECT id FROM permissions WHERE code = 'health.patient.export')",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 软删除权限
|
||||
db.execute_unprepared(
|
||||
"UPDATE permissions SET deleted_at = NOW() \
|
||||
WHERE code = 'health.patient.export' AND deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn assign_perms_by_codes(
|
||||
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
|
||||
role_code: &str,
|
||||
perm_codes: &[&str],
|
||||
) -> Result<(), DbErr> {
|
||||
let codes_csv: String = perm_codes
|
||||
.iter()
|
||||
.map(|c| format!("'{}'", c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
db.execute_unprepared(&format!(
|
||||
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||
FROM roles r \
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \
|
||||
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
|
||||
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
|
||||
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assign_single_perm(
|
||||
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
|
||||
role_code: &str,
|
||||
perm_code: &str,
|
||||
) -> Result<(), DbErr> {
|
||||
db.execute_unprepared(&format!(
|
||||
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||
FROM roles r \
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{perm_code}' AND p.deleted_at IS NULL \
|
||||
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
|
||||
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
|
||||
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -188,9 +188,30 @@ impl AppConfig {
|
||||
.build()?;
|
||||
let app_config: Self = config.try_deserialize()?;
|
||||
|
||||
// 安全检查:禁止在生产使用默认 JWT 密钥
|
||||
if app_config.jwt.secret == "change-me-in-production" {
|
||||
tracing::warn!("⚠️ JWT 密钥使用默认值,请通过 ERP__JWT__SECRET 环境变量设置安全密钥");
|
||||
// 安全检查:禁止在生产使用占位/默认 JWT 密钥。
|
||||
// 与 KEK(见 main.rs:453 `== "__MUST_SET_VIA_ENV__"`)/storage_key(见 default_secret_key)
|
||||
// 的 release panic 防护对称。关键风险:生产忘设 ERP__JWT__SECRET 时,secret 取
|
||||
// config/default.toml 的占位符值并静默运行,任意 token 可被伪造。
|
||||
// 注:原检查常量 "change-me-in-production" 在仓库中无任何注入源(doc-code drift 死代码),
|
||||
// 已改为覆盖全部已知不安全值。详见上线评估 B4 + 症状导航。
|
||||
const UNSAFE_JWT_SECRETS: &[&str] = &[
|
||||
"__MUST_SET_VIA_ENV__", // config/default.toml 占位符(与 KEK 检查一致)
|
||||
"dev-secret-key-change-in-prod", // dev.ps1 注入的开发值
|
||||
"change-me-in-production", // 历史遗留默认值(向后兼容)
|
||||
];
|
||||
if UNSAFE_JWT_SECRETS.contains(&app_config.jwt.secret.as_str()) {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
tracing::warn!(
|
||||
"⚠️ JWT 密钥使用占位/开发默认值(开发环境允许),生产环境将 panic;请通过 ERP__JWT__SECRET 设置安全密钥"
|
||||
);
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
panic!(
|
||||
"ERP__JWT__SECRET 必须设置安全密钥(生产环境不允许使用占位/默认 JWT 密钥,否则任意 token 可被伪造)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(app_config)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use axum::response::Json;
|
||||
use serde_json::Value;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc};
|
||||
@@ -7,12 +6,20 @@ use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc};
|
||||
/// GET /docs/openapi.json
|
||||
///
|
||||
/// 返回 OpenAPI 3.0 规范 JSON 文档,合并所有模块的路径和 schema。
|
||||
pub async fn openapi_spec() -> Json<Value> {
|
||||
let mut spec = ApiDoc::openapi();
|
||||
spec.merge(AuthApiDoc::openapi());
|
||||
spec.merge(ConfigApiDoc::openapi());
|
||||
spec.merge(WorkflowApiDoc::openapi());
|
||||
spec.merge(MessageApiDoc::openapi());
|
||||
/// 仅在 debug 模式下可用,生产构建返回 404。
|
||||
pub async fn openapi_spec() -> Response {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let mut spec = ApiDoc::openapi();
|
||||
spec.merge(AuthApiDoc::openapi());
|
||||
spec.merge(ConfigApiDoc::openapi());
|
||||
spec.merge(WorkflowApiDoc::openapi());
|
||||
spec.merge(MessageApiDoc::openapi());
|
||||
Json(serde_json::to_value(spec).unwrap_or_default()).into_response()
|
||||
}
|
||||
|
||||
Json(serde_json::to_value(spec).unwrap_or_default())
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
(axum::http::StatusCode::NOT_FOUND, "Not Found").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,12 +437,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone());
|
||||
tracing::info!("Outbox relay started");
|
||||
|
||||
// Start event cleanup (archive old published events + purge processed_events)
|
||||
tasks::start_event_cleanup(db.clone());
|
||||
|
||||
// Start DB connection pool metrics sampling (every 30s)
|
||||
tasks::start_pool_metrics(db.clone());
|
||||
|
||||
// Start timeout checker (scan overdue tasks every 60s)
|
||||
erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone());
|
||||
tracing::info!("Timeout checker started");
|
||||
@@ -568,12 +562,26 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let embedding_svc = std::sync::Arc::new(
|
||||
erp_ai::service::embedding::EmbeddingService::from_settings(&db).await,
|
||||
);
|
||||
let knowledge_v2_svc = std::sync::Arc::new(
|
||||
erp_ai::service::knowledge_v2::KnowledgeV2Service::new(db.clone()),
|
||||
);
|
||||
|
||||
let analysis_svc =
|
||||
erp_ai::service::analysis::AnalysisService::new(registry.clone(), db.clone())
|
||||
.with_knowledge_source(std::sync::Arc::new(
|
||||
erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(
|
||||
db.clone(),
|
||||
),
|
||||
))
|
||||
.with_knowledge_source(std::sync::Arc::new(
|
||||
erp_ai::knowledge::v2_source::KnowledgeV2Source::new(
|
||||
db.clone(),
|
||||
knowledge_v2_svc.clone(),
|
||||
embedding_svc.clone(),
|
||||
),
|
||||
));
|
||||
let analysis = std::sync::Arc::new(analysis_svc);
|
||||
let prompt = std::sync::Arc::new(erp_ai::service::prompt::PromptService::new(db.clone()));
|
||||
@@ -594,13 +602,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
cache_ttl,
|
||||
));
|
||||
|
||||
let embedding_svc = std::sync::Arc::new(
|
||||
erp_ai::service::embedding::EmbeddingService::from_settings(&db).await,
|
||||
);
|
||||
let knowledge_v2_svc = std::sync::Arc::new(
|
||||
erp_ai::service::knowledge_v2::KnowledgeV2Service::new(db.clone()),
|
||||
);
|
||||
|
||||
erp_ai::AiState {
|
||||
db: db.clone(),
|
||||
event_bus: event_bus.clone(),
|
||||
@@ -643,6 +644,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone());
|
||||
tracing::info!("Auto trend analysis scheduler started");
|
||||
|
||||
// Start analysis queue worker (claims pending ai_analysis_queue jobs → analyzes → completes)
|
||||
erp_ai::service::analysis_worker::start_analysis_worker(ai_state.clone());
|
||||
tracing::info!("AI analysis queue worker started");
|
||||
|
||||
let cron_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
));
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
config,
|
||||
@@ -657,8 +669,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
.build(),
|
||||
ai_state,
|
||||
pii_crypto,
|
||||
cron_heartbeat: cron_heartbeat.clone(),
|
||||
};
|
||||
|
||||
// Start background tasks with heartbeat
|
||||
tasks::start_event_cleanup(state.db.clone(), state.cron_heartbeat.clone());
|
||||
tasks::start_pool_metrics(state.db.clone(), state.cron_heartbeat.clone());
|
||||
tasks::start_retry_dead_letters(
|
||||
state.db.clone(),
|
||||
state.event_bus.clone(),
|
||||
state.cron_heartbeat.clone(),
|
||||
);
|
||||
|
||||
// --- Build the router ---
|
||||
//
|
||||
// The router is split into two layers:
|
||||
@@ -684,6 +706,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
// WeChat routes — higher rate limit (30/min) for mobile users
|
||||
let wechat_routes = Router::new()
|
||||
.merge(erp_auth::AuthModule::wechat_routes())
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::rate_limit::rate_limit_wechat,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
// Refresh token routes — higher rate limit (30/min) than login (5/min)
|
||||
let refresh_routes = Router::new()
|
||||
.merge(erp_auth::AuthModule::refresh_routes())
|
||||
@@ -773,6 +804,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
unthrottled_routes
|
||||
.merge(public_routes)
|
||||
.merge(refresh_routes)
|
||||
.merge(wechat_routes)
|
||||
.merge(protected_routes)
|
||||
.nest("/fhir", fhir_routes),
|
||||
)
|
||||
|
||||
@@ -86,6 +86,32 @@ pub async fn rate_limit_refresh_by_ip(
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的 IP 限流中间件(微信登录/绑定,30 次/分钟)。
|
||||
///
|
||||
/// 移动端用户可能频繁重试,使用与 token 刷新相同的宽松配额。
|
||||
/// 独立于密码登录的 5 次/分钟严格限制。
|
||||
pub async fn rate_limit_wechat(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = extract_client_ip(req.headers());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 30,
|
||||
window_secs: 60,
|
||||
prefix: "wechat",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的用户限流中间件。
|
||||
///
|
||||
/// 从 TenantContext 中读取 user_id 作为标识符。
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use erp_core::events::{EventBus, retry_dead_letters};
|
||||
|
||||
fn touch_heartbeat(heartbeat: &Arc<AtomicU64>) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
heartbeat.store(now, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 启动事件清理后台任务。
|
||||
///
|
||||
/// 每日执行一次:
|
||||
/// - 调用 `cleanup_old_published_events()` 归档 >7 天的已发布事件
|
||||
/// - 调用 `cleanup_old_processed_events()` 清理 >7 天的去重记录
|
||||
pub fn start_event_cleanup(db: sea_orm::DatabaseConnection) {
|
||||
pub fn start_event_cleanup(db: sea_orm::DatabaseConnection, heartbeat: Arc<AtomicU64>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(86400));
|
||||
loop {
|
||||
@@ -13,6 +25,7 @@ pub fn start_event_cleanup(db: sea_orm::DatabaseConnection) {
|
||||
if let Err(e) = run_cleanup(&db).await {
|
||||
tracing::warn!(error = %e, "事件清理任务执行失败");
|
||||
}
|
||||
touch_heartbeat(&heartbeat);
|
||||
}
|
||||
});
|
||||
tracing::info!("事件清理任务已启动(每 24 小时执行一次)");
|
||||
@@ -52,13 +65,14 @@ async fn run_cleanup(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm::Db
|
||||
/// - `db_pool_connections_active` — 当前活跃连接数
|
||||
/// - `db_pool_connections_idle` — 当前空闲连接数
|
||||
/// - `eventbus_pending_total` — pending 状态的领域事件数
|
||||
pub fn start_pool_metrics(db: sea_orm::DatabaseConnection) {
|
||||
pub fn start_pool_metrics(db: sea_orm::DatabaseConnection, heartbeat: Arc<AtomicU64>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
sample_pool_metrics(&db).await;
|
||||
sample_eventbus_backlog(&db).await;
|
||||
touch_heartbeat(&heartbeat);
|
||||
}
|
||||
});
|
||||
tracing::info!("DB 连接池 + EventBus 积压指标采样已启动(每 30 秒采样一次)");
|
||||
@@ -111,3 +125,40 @@ async fn sample_eventbus_backlog(db: &sea_orm::DatabaseConnection) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动死信重试后台任务。
|
||||
///
|
||||
/// 每小时执行一次:
|
||||
/// - 调用 `erp_core::events::retry_dead_letters()` 重试 `dead_letter_events` 中
|
||||
/// 未解决且未超过最大重试次数的失败事件(指数退避由 attempts + last_error 记录)
|
||||
/// - 最大重试 5 次,超过则标记永久失败
|
||||
///
|
||||
/// 触碰「每个事件必须有消费者」铁律的兜底:业务关键链路(危急值告警/积分发放/
|
||||
/// 预约提醒/article 推送)的瞬时故障借此自动恢复,不再永久滞留死信表。
|
||||
pub fn start_retry_dead_letters(
|
||||
db: sea_orm::DatabaseConnection,
|
||||
bus: EventBus,
|
||||
heartbeat: Arc<AtomicU64>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
// 首次延迟 60s,避免与启动期 outbox relay 抢资源
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match retry_dead_letters(&db, &bus, 5).await {
|
||||
Ok(retried) if retried > 0 => {
|
||||
tracing::info!(retried, "死信重试任务完成(已重试 N 条)");
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::debug!("死信重试任务完成(无待重试事件)");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "死信重试任务执行失败");
|
||||
}
|
||||
}
|
||||
touch_heartbeat(&heartbeat);
|
||||
}
|
||||
});
|
||||
tracing::info!("死信重试任务已启动(每 1 小时执行一次,最大重试 5 次)");
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ mod health_follow_up_template_tests;
|
||||
mod health_follow_up_tests;
|
||||
#[path = "integration/health_medication_tests.rs"]
|
||||
mod health_medication_tests;
|
||||
#[path = "integration/health_patient_export_tests.rs"]
|
||||
mod health_patient_export_tests;
|
||||
#[path = "integration/health_patient_tests.rs"]
|
||||
mod health_patient_tests;
|
||||
#[path = "integration/health_pii_encryption_tests.rs"]
|
||||
|
||||
@@ -58,6 +58,7 @@ async fn prompt_create_and_get() {
|
||||
"Analyze: {{data}}".into(),
|
||||
serde_json::json!({"model": "claude"}),
|
||||
"analysis".into(),
|
||||
"lab_report".into(),
|
||||
)
|
||||
.await
|
||||
.expect("创建应成功");
|
||||
@@ -82,7 +83,11 @@ async fn prompt_list_with_category_filter() {
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
|
||||
for (name, cat) in [("p1", "analysis"), ("p2", "summary"), ("p3", "analysis")] {
|
||||
for (name, cat, at) in [
|
||||
("p1", "analysis", "lab_report"),
|
||||
("p2", "summary", "trends"),
|
||||
("p3", "analysis", "report_summary"),
|
||||
] {
|
||||
svc.create_prompt(
|
||||
tenant_id,
|
||||
user_id,
|
||||
@@ -91,15 +96,17 @@ async fn prompt_list_with_category_filter() {
|
||||
"usr".into(),
|
||||
serde_json::json!({}),
|
||||
cat.into(),
|
||||
at.into(),
|
||||
)
|
||||
.await
|
||||
.expect("创建应成功");
|
||||
}
|
||||
|
||||
// list_prompts 现在按 analysis_type 过滤
|
||||
let (items, total) = svc
|
||||
.list_prompts(
|
||||
tenant_id,
|
||||
Some("analysis".into()),
|
||||
Some("lab_report".into()),
|
||||
&Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
@@ -108,8 +115,9 @@ async fn prompt_list_with_category_filter() {
|
||||
.await
|
||||
.expect("查询应成功");
|
||||
|
||||
assert_eq!(total, 2);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].name, "p1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -128,6 +136,7 @@ async fn prompt_activate_switches_version() {
|
||||
"usr".into(),
|
||||
serde_json::json!({}),
|
||||
"cat".into(),
|
||||
"lab_report".into(),
|
||||
)
|
||||
.await
|
||||
.expect("v1");
|
||||
@@ -147,9 +156,9 @@ async fn prompt_activate_switches_version() {
|
||||
|
||||
assert_eq!(v2.version, 2);
|
||||
|
||||
// v1 仍然激活(update 继承 is_active)
|
||||
// v1 仍然激活(update 继承 is_active),按 analysis_type 查找
|
||||
let active_before = svc
|
||||
.get_active_prompt(tenant_id, "my_prompt")
|
||||
.get_active_prompt(tenant_id, "lab_report")
|
||||
.await
|
||||
.expect("active");
|
||||
assert_eq!(active_before.system_prompt, "sys_v1");
|
||||
@@ -160,7 +169,7 @@ async fn prompt_activate_switches_version() {
|
||||
.expect("activate");
|
||||
|
||||
let active_after = svc
|
||||
.get_active_prompt(tenant_id, "my_prompt")
|
||||
.get_active_prompt(tenant_id, "lab_report")
|
||||
.await
|
||||
.expect("active");
|
||||
assert_eq!(active_after.id, v2.id);
|
||||
@@ -187,6 +196,7 @@ async fn prompt_rollback_equals_activate() {
|
||||
"usr".into(),
|
||||
serde_json::json!({}),
|
||||
"cat".into(),
|
||||
"lab_report".into(),
|
||||
)
|
||||
.await
|
||||
.expect("v1");
|
||||
@@ -214,7 +224,7 @@ async fn prompt_rollback_equals_activate() {
|
||||
.expect("rollback");
|
||||
|
||||
let active = svc
|
||||
.get_active_prompt(tenant_id, "rb_test")
|
||||
.get_active_prompt(tenant_id, "lab_report")
|
||||
.await
|
||||
.expect("active");
|
||||
assert_eq!(active.id, v1.id);
|
||||
@@ -236,6 +246,7 @@ async fn prompt_cross_tenant_isolation() {
|
||||
"usr".into(),
|
||||
serde_json::json!({}),
|
||||
"cat".into(),
|
||||
"lab_report".into(),
|
||||
)
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -496,7 +507,7 @@ async fn analysis_list_with_filters() {
|
||||
.await;
|
||||
|
||||
// 按 patient 筛选
|
||||
let (items, total) = svc
|
||||
let (_items, total) = svc
|
||||
.list_analysis(
|
||||
tenant_id,
|
||||
Some(patient_a),
|
||||
@@ -511,7 +522,7 @@ async fn analysis_list_with_filters() {
|
||||
assert_eq!(total, 2);
|
||||
|
||||
// 按 type 筛选
|
||||
let (items, total) = svc
|
||||
let (_items, total) = svc
|
||||
.list_analysis(
|
||||
tenant_id,
|
||||
None,
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn test_user_crud() {
|
||||
page_size: Some(10),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
@@ -90,6 +91,7 @@ async fn test_tenant_isolation() {
|
||||
page_size: Some(10),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! 个保法 §45 患者数据导出集成测试
|
||||
//!
|
||||
//! 验证双格式导出、明文 PII、跨租户隔离、审计不含明文 PII、FHIR 脱敏。
|
||||
//! 直接调用 service 层(不走 HTTP/认证/consent),与 health_patient_tests.rs 一致。
|
||||
|
||||
use erp_core::crypto::PiiCrypto;
|
||||
use erp_core::entity::audit_log;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_health::dto::export_dto::ExportFormat;
|
||||
use erp_health::dto::patient_dto::CreatePatientReq;
|
||||
use erp_health::service::patient_service;
|
||||
use erp_health::state::HealthState;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
use super::test_db::TestDb;
|
||||
|
||||
/// 构建测试用 HealthState
|
||||
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
|
||||
HealthState {
|
||||
db: db.clone(),
|
||||
event_bus: EventBus::new(100),
|
||||
crypto: PiiCrypto::dev_default(),
|
||||
jwt_secret: "test-jwt-secret".to_string(),
|
||||
external_health_checks: vec![],
|
||||
cron_heartbeat: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造带完整 PII 的患者请求
|
||||
fn make_patient_req() -> CreatePatientReq {
|
||||
CreatePatientReq {
|
||||
name: "张三".to_string(),
|
||||
gender: Some("male".to_string()),
|
||||
birth_date: Some(chrono::NaiveDate::from_ymd_opt(1990, 1, 15).unwrap()),
|
||||
blood_type: Some("A".to_string()),
|
||||
id_number: Some("110101199001151234".to_string()),
|
||||
allergy_history: Some("青霉素过敏".to_string()),
|
||||
medical_history_summary: Some("高血压病史3年".to_string()),
|
||||
emergency_contact_name: Some("李四".to_string()),
|
||||
emergency_contact_phone: Some("13800138000".to_string()),
|
||||
source: Some("offline".to_string()),
|
||||
notes: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_json_contains_plaintext_pii() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
|
||||
let patient =
|
||||
patient_service::create_patient(&state, tenant_id, Some(operator_id), make_patient_req())
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
let resp = patient_service::export_patient(
|
||||
&state,
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
patient.id,
|
||||
ExportFormat::Json,
|
||||
)
|
||||
.await
|
||||
.expect("json 导出应成功");
|
||||
|
||||
assert_eq!(resp.format, ExportFormat::Json);
|
||||
// §45 可携权本意:json 导出含明文 PII(非脱敏)
|
||||
assert_eq!(
|
||||
resp.payload["patient"]["id_number"].as_str().unwrap(),
|
||||
"110101199001151234",
|
||||
"json 导出应含明文身份证号"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.payload["patient"]["allergy_history"].as_str().unwrap(),
|
||||
"青霉素过敏"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.payload["patient"]["medical_history_summary"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
"高血压病史3年"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.payload["patient"]["emergency_contact_phone"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
"13800138000"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_fhir_returns_bundle() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
let patient = patient_service::create_patient(&state, tenant_id, None, make_patient_req())
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
let resp =
|
||||
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Fhir)
|
||||
.await
|
||||
.expect("fhir 导出应成功");
|
||||
|
||||
assert_eq!(resp.format, ExportFormat::Fhir);
|
||||
assert_eq!(resp.payload["resourceType"], "Bundle");
|
||||
assert_eq!(resp.payload["type"], "collection");
|
||||
assert_eq!(
|
||||
resp.payload["entry"][0]["resource"]["resourceType"],
|
||||
"Patient"
|
||||
);
|
||||
assert!(
|
||||
resp.payload["total"].as_u64().unwrap() >= 1,
|
||||
"Bundle 至少含 Patient 资源"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_empty_patient_no_error() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
let req = CreatePatientReq {
|
||||
name: "空患者".to_string(),
|
||||
gender: None,
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
source: None,
|
||||
notes: None,
|
||||
};
|
||||
let patient = patient_service::create_patient(&state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
let resp =
|
||||
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Json)
|
||||
.await
|
||||
.expect("空患者导出不应报错");
|
||||
|
||||
assert_eq!(resp.payload["device_readings"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(resp.resource_counts["observations"].as_u64().unwrap(), 0);
|
||||
assert_eq!(resp.resource_counts["appointments"].as_u64().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_cross_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
|
||||
let patient = patient_service::create_patient(&state, tenant_a, None, make_patient_req())
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
// tenant_b 尝试导出 tenant_a 的患者 → find_packet 按 tenant 过滤 → NotFound
|
||||
let result =
|
||||
patient_service::export_patient(&state, tenant_b, None, patient.id, ExportFormat::Json)
|
||||
.await;
|
||||
assert!(result.is_err(), "跨租户导出应失败");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_writes_audit_without_plaintext() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
|
||||
let patient =
|
||||
patient_service::create_patient(&state, tenant_id, Some(operator_id), make_patient_req())
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
patient_service::export_patient(
|
||||
&state,
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
patient.id,
|
||||
ExportFormat::Json,
|
||||
)
|
||||
.await
|
||||
.expect("导出应成功");
|
||||
|
||||
let logs = audit_log::Entity::find()
|
||||
.filter(audit_log::Column::Action.eq("patient.exported"))
|
||||
.filter(audit_log::Column::ResourceId.eq(patient.id))
|
||||
.all(test_db.db())
|
||||
.await
|
||||
.expect("查 audit 应成功");
|
||||
|
||||
assert_eq!(logs.len(), 1, "应写一条 patient.exported 审计");
|
||||
let new_value = logs[0].new_value.as_ref().expect("new_value 应存在");
|
||||
let new_value_str = new_value.to_string();
|
||||
assert!(new_value_str.contains("json"), "审计应记录 format=json");
|
||||
assert!(
|
||||
!new_value_str.contains("110101199001151234"),
|
||||
"审计 new_value 绝不能含明文身份证号"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_fhir_redacts_id_number() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
let patient = patient_service::create_patient(&state, tenant_id, None, make_patient_req())
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
let resp =
|
||||
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Fhir)
|
||||
.await
|
||||
.expect("fhir 导出应成功");
|
||||
|
||||
// FHIR Bundle 内 Patient.identifier.value 应脱敏或 REDACTED,非明文
|
||||
let id_val = resp.payload["entry"][0]["resource"]["identifier"][0]["value"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
assert_ne!(
|
||||
id_val, "110101199001151234",
|
||||
"FHIR 路径不应输出明文身份证号"
|
||||
);
|
||||
assert!(
|
||||
id_val.contains('*') || id_val == "[REDACTED]",
|
||||
"应为脱敏(含*)或 [REDACTED],实际: {id_val}"
|
||||
);
|
||||
}
|
||||
@@ -68,3 +68,10 @@ UPLOADS_BACKUP_CRON=0 3 * * *
|
||||
# Grafana 管理员密码
|
||||
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_GRAFANA_ADMIN
|
||||
GRAFANA_ROOT_URL=http://localhost:3001
|
||||
|
||||
# ===== 监控告警 =====
|
||||
|
||||
# Alertmanager 告警通知出口(上线前必填,否则 DB 宕机/5xx 飙升等告警发不到任何人)
|
||||
# 钉钉机器人:https://oapi.dingtalk.com/robot/send?access_token=XXX
|
||||
# 企业微信群机器人:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=XXX
|
||||
ALERT_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=CHANGE_ME
|
||||
|
||||
63
docker/alertmanager/README.md
Normal file
63
docker/alertmanager/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Alertmanager 告警通知配置
|
||||
|
||||
> PP-04 可观测性。当前 `config.yml` 使用占位 webhook(`http://placeholder.invalid/alert`),告警会 POST 失败但记日志。
|
||||
> **上线前必须**替换为真实通知渠道,否则 11 条告警规则触发了也没人收到。
|
||||
|
||||
alertmanager 已启用 `--config.expand-env=true`,支持 `${VAR}` 从环境变量展开。
|
||||
|
||||
## 方案 A:钉钉 / 企业微信 webhook(推荐)
|
||||
|
||||
1. `config.yml` 的 receiver 改为环境变量引用:
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
- name: "default"
|
||||
webhook_configs:
|
||||
- url: "${ALERT_WEBHOOK_URL}"
|
||||
send_resolved: true
|
||||
```
|
||||
|
||||
2. `.env`(不入 git)加:
|
||||
```
|
||||
# 钉钉机器人
|
||||
ALERT_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=XXX
|
||||
# 或企业微信群机器人
|
||||
# ALERT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=XXX
|
||||
```
|
||||
|
||||
> token 必须放 `.env`,不能写进 `config.yml`(git 追踪)——避免重蹈 PP-03 Redis 密码明文泄露覆辙。
|
||||
|
||||
## 方案 B:邮件 SMTP
|
||||
|
||||
```yaml
|
||||
global:
|
||||
smtp_smarthost: "smtp.exmail.qq.com:465"
|
||||
smtp_from: "alert@hms.example.com"
|
||||
smtp_auth_username: "alert@hms.example.com"
|
||||
smtp_auth_password: "${SMTP_PASSWORD}"
|
||||
receivers:
|
||||
- name: "default"
|
||||
email_configs:
|
||||
- to: "ops@hms.example.com"
|
||||
send_resolved: true
|
||||
```
|
||||
|
||||
`.env` 加 `SMTP_PASSWORD=...`。
|
||||
|
||||
## 验证
|
||||
|
||||
部署后用 Alertmanager API 触发测试告警:
|
||||
|
||||
```bash
|
||||
curl -XPOST http://<host>:9093/api/v2/alerts \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"labels":{"alertname":"test","severity":"critical"}}]'
|
||||
```
|
||||
|
||||
应收到渠道通知(钉钉/企微/邮件)。Alertmanager UI:`http://<host>:9093`。
|
||||
|
||||
## 当前路由策略
|
||||
|
||||
- 按 `alertname + service` 分组
|
||||
- `severity=critical`(DB 宕机/5xx 飙升/Redis 不可达)即时通知,5 分钟重复
|
||||
- 其他告警 30s 聚合,4 小时重复
|
||||
39
docker/alertmanager/config.yml
Normal file
39
docker/alertmanager/config.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Alertmanager 告警通知配置
|
||||
#
|
||||
# 通知渠道由 ALERT_WEBHOOK_URL 环境变量注入(见 receivers.default.webhook_configs),
|
||||
# 容器启用 --config.expand-env=true 展开。来源:docker/.env.production。
|
||||
#
|
||||
# ⚠️ 上线前必填(docker/.env.production.example 已给模板),否则告警发不到任何人:
|
||||
# - 钉钉机器人:https://oapi.dingtalk.com/robot/send?access_token=XXX
|
||||
# - 企业微信群机器人:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=XXX
|
||||
# - 邮件 SMTP:配置 global.smtp_* + email_configs
|
||||
#
|
||||
# 未配置 ALERT_WEBHOOK_URL 时,compose 层 fallback 占位 url,alertmanager 可启动
|
||||
# 但 POST 失败 —— fail-fast 优于 PP-04 之前"告警触发无人知晓"的盲飞状态。
|
||||
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
# 路由:按 alertname + service 分组,先 SEV-1(critical)走即时通知
|
||||
route:
|
||||
receiver: "default"
|
||||
group_by: ["alertname", "service"]
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
routes:
|
||||
# SEV-1 关键告警(DB 宕机/5xx 飙升/Redis 不可达)立即通知,5 分钟重复
|
||||
- matchers:
|
||||
- severity = "critical"
|
||||
receiver: "default"
|
||||
group_wait: 0s
|
||||
repeat_interval: 5m
|
||||
|
||||
receivers:
|
||||
- name: "default"
|
||||
# 真实通知渠道由 ALERT_WEBHOOK_URL 环境变量注入(alertmanager 启用 --config.expand-env=true)。
|
||||
# 上线前必填:见 docker/.env.production.example。未配置时 compose 层 fallback 占位 url,
|
||||
# alertmanager 可启动但 POST 失败 —— fail-fast 优于静默发到无效端点。
|
||||
webhook_configs:
|
||||
- url: "${ALERT_WEBHOOK_URL}"
|
||||
send_resolved: true
|
||||
@@ -13,7 +13,7 @@ BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
PG_HOST="${PGHOST:-postgres}"
|
||||
PG_PORT="${PGPORT:-5432}"
|
||||
PG_USER="${PGUSER:-erp}"
|
||||
PG_DB="${PGDATABSE:-erp}"
|
||||
PG_DB="${PGDATABASE:-erp}"
|
||||
KEEP_DAYS="${KEEP_DAYS:-7}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
FILENAME="${PG_DB}_${TIMESTAMP}.sql.gz"
|
||||
|
||||
@@ -134,6 +134,28 @@ services:
|
||||
networks:
|
||||
- hms-internal
|
||||
|
||||
# ── Alertmanager 告警通知出口 ──
|
||||
# PP-04: 之前 11 条告警规则在 prometheus 加载但无 alertmanager,告警触发无人知晓
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
container_name: hms-alertmanager
|
||||
restart: unless-stopped
|
||||
# ALERT_WEBHOOK_URL 从宿主机 .env.production 注入容器,供 config.yml 的 ${ALERT_WEBHOOK_URL} 展开。
|
||||
# 未配置时 fallback 占位 url,保持 MVP 链路可启动;上线前在 .env.production 填真实钉钉/企微 webhook。
|
||||
environment:
|
||||
ALERT_WEBHOOK_URL: "${ALERT_WEBHOOK_URL:-http://placeholder.invalid/alert}"
|
||||
volumes:
|
||||
- ./alertmanager/config.yml:/etc/alertmanager/config.yml:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/config.yml"
|
||||
- "--config.expand-env=true"
|
||||
- "--storage.path=/alertmanager"
|
||||
expose:
|
||||
- "9093"
|
||||
networks:
|
||||
- hms-internal
|
||||
|
||||
# ── Grafana 可视化 ──
|
||||
grafana:
|
||||
image: grafana/grafana:11.4.0
|
||||
@@ -154,6 +176,30 @@ services:
|
||||
networks:
|
||||
- hms-internal
|
||||
|
||||
# ── Prometheus exporters(PP-04:之前 prometheus.yml 配了 target 但服务未部署,告警永不触发)──
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||
container_name: hms-postgres-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER:-erp}:${POSTGRES_PASSWORD}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-erp}?sslmode=disable"
|
||||
expose:
|
||||
- "9187"
|
||||
networks:
|
||||
- hms-internal
|
||||
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:v1.66.0
|
||||
container_name: hms-redis-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_ADDR: "redis://redis:${REDIS_PORT:-6379}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD:-erp_redis_dev}"
|
||||
expose:
|
||||
- "9121"
|
||||
networks:
|
||||
- hms-internal
|
||||
|
||||
volumes:
|
||||
app-uploads:
|
||||
driver: local
|
||||
@@ -167,6 +213,8 @@ volumes:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
alertmanager_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
hms-internal:
|
||||
|
||||
15
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
15
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Grafana dashboard provider
|
||||
# 自动加载 ./json/ 下的 dashboard JSON 文件(PP-04 后续补充 HMS 概览 dashboard)
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: "HMS Dashboards"
|
||||
orgId: 1
|
||||
folder: ""
|
||||
type: file
|
||||
disableDeletion: false
|
||||
editable: true
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards/json
|
||||
foldersFromFilesStructure: false
|
||||
115
docker/grafana/provisioning/dashboards/json/hms-overview.json
Normal file
115
docker/grafana/provisioning/dashboards/json/hms-overview.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"uid": "hms-overview",
|
||||
"title": "HMS 概览",
|
||||
"tags": ["HMS", "overview"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "HMS 服务状态",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "up{job=\"hms\"}", "legendFormat": "" }],
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"colorMode": "background",
|
||||
"mappings": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"0": { "text": "DOWN", "color": "red", "index": 0 },
|
||||
"1": { "text": "UP", "color": "green", "index": 1 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "EventBus 积压 (pending)",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "eventbus_pending_total", "legendFormat": "" }],
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"colorMode": "value",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 100 },
|
||||
{ "color": "red", "value": 500 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "API 5xx 错误率 (5m)",
|
||||
"gridPos": { "h": 4, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) / clamp_min(sum(rate(http_requests_total[5m])), 1)",
|
||||
"legendFormat": "5xx ratio"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"colorMode": "background",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.01 },
|
||||
{ "color": "red", "value": 0.05 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"fieldConfig": { "defaults": { "unit": "percentunit" } }
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "timeseries",
|
||||
"title": "DB 连接池(活跃 / 空闲)",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "db_pool_connections_active", "legendFormat": "活跃" },
|
||||
{ "refId": "B", "expr": "db_pool_connections_idle", "legendFormat": "空闲" }
|
||||
],
|
||||
"fieldConfig": { "defaults": { "unit": "short" } }
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "进程内存 / CPU",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "process_resident_memory_bytes", "legendFormat": "内存 (bytes)" },
|
||||
{ "refId": "B", "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "CPU (cores/s)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "timeseries",
|
||||
"title": "EventBus 积压趋势",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 12 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "eventbus_pending_total", "legendFormat": "pending events" }
|
||||
],
|
||||
"fieldConfig": { "defaults": { "unit": "short" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
14
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
14
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Grafana 数据源自动 provisioning
|
||||
# 启动时自动注册 Prometheus 数据源,无需手动在 UI 配置
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
@@ -5,6 +5,11 @@ global:
|
||||
rule_files:
|
||||
- "alerts.yml"
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ["alertmanager:9093"]
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "hms"
|
||||
metrics_path: /metrics
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
### C-01 Redis 凭据硬编码在配置文件中(泄露到 Git)
|
||||
|
||||
- **文件**: `crates/erp-server/config/default.toml` (line 11)
|
||||
- **现象**: `url = "redis://:redis_KBCYJk@129.204.154.246:6379"` 硬编码了远程 Redis 密码和 IP
|
||||
- **现象**: `url = "redis://:<REDACTED>@<redacted>:6379"` 硬编码了远程 Redis 密码和 IP(明文已于 2026-06-25 从仓库清除,密码已轮换;保留本条作为历史审计证据)
|
||||
- **影响**: 凭据已提交到 Git 仓库,任何有代码访问权限的人都能获取 Redis 密码和服务器 IP
|
||||
- **修复**:
|
||||
1. 立即轮换 Redis 密码
|
||||
|
||||
879
docs/design/veepoo-measure-prototype.html
Normal file
879
docs/design/veepoo-measure-prototype.html
Normal file
@@ -0,0 +1,879 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Veepoo M2 手环 — 测量页 & 上传页原型</title>
|
||||
<style>
|
||||
/* ═══════════════════════════════════════
|
||||
Design Token — 复刻小程序 var(--tk-*)
|
||||
═══════════════════════════════════════ */
|
||||
:root {
|
||||
--tk-pri: #C4623A;
|
||||
--tk-pri-l: #F0DDD4;
|
||||
--tk-pri-d: #8B3E1F;
|
||||
--tk-pri-surface: #F5F0EB;
|
||||
--tk-acc: #5B7A5E;
|
||||
--tk-acc-l: #E8F0E8;
|
||||
--tk-bg: #F5F0EB;
|
||||
--tk-card: #FFFFFF;
|
||||
--tk-tx: #2D2A26;
|
||||
--tk-tx2: #5A554F;
|
||||
--tk-tx3: #78716C;
|
||||
--tk-bd: #E8E2DC;
|
||||
--tk-dan: #B54A4A;
|
||||
--tk-dan-l: #FDEAEA;
|
||||
--tk-wrn: #C4873A;
|
||||
--tk-wrn-l: #FFF3E0;
|
||||
|
||||
--tk-font-h2: 22px;
|
||||
--tk-font-body-lg: 18px;
|
||||
--tk-font-body: 16px;
|
||||
--tk-font-body-sm: 14px;
|
||||
--tk-font-num: 30px;
|
||||
--tk-font-num-lg: 34px;
|
||||
--tk-font-cap: 13px;
|
||||
--tk-font-micro: 11px;
|
||||
--tk-line-height: 1.5;
|
||||
|
||||
--tk-card-radius: 16px;
|
||||
--tk-gap-sm: 12px;
|
||||
--tk-gap-md: 16px;
|
||||
--tk-gap-lg: 24px;
|
||||
--tk-gap-xl: 32px;
|
||||
--tk-page-padding: 20px;
|
||||
|
||||
--tk-shadow-sm: 0 1px 4px rgba(45,42,38,0.06);
|
||||
--tk-shadow-md: 0 2px 12px rgba(45,42,38,0.10);
|
||||
--tk-shadow-btn: 0 4px 16px rgba(196,98,58,0.3);
|
||||
|
||||
--tk-duration-fast: 150ms;
|
||||
--tk-duration-normal: 200ms;
|
||||
--tk-easing: cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
background: #E8E2DC;
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
Phone Frame — 模拟小程序容器
|
||||
═══════════════════════════════════════ */
|
||||
.phone {
|
||||
width: 375px;
|
||||
height: 812px;
|
||||
background: var(--tk-bg);
|
||||
border-radius: 40px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.phone-inner {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.phone-label {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--tk-tx3);
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.status-bar {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--tk-tx);
|
||||
}
|
||||
.status-bar-right {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
页面 1 — 测量页面(就绪态 + 测量中心率)
|
||||
═══════════════════════════════════════ */
|
||||
.measure-page {
|
||||
background: var(--tk-bg);
|
||||
min-height: 100%;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 设备状态栏 */
|
||||
.device-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px var(--tk-page-padding);
|
||||
background: var(--tk-card);
|
||||
border-bottom: 1px solid var(--tk-bd);
|
||||
}
|
||||
.device-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.device-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--tk-acc);
|
||||
}
|
||||
.device-bar__name {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: var(--tk-tx);
|
||||
}
|
||||
.device-bar__battery {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-tx3);
|
||||
margin-left: 4px;
|
||||
}
|
||||
.device-bar__disconnect {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-tx3);
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--tk-bd);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 指标选择器 — 横向滚动药丸 */
|
||||
.selector {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: var(--tk-gap-md) var(--tk-page-padding);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.selector::-webkit-scrollbar { display: none; }
|
||||
.selector__pill {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--tk-duration-normal) var(--tk-easing);
|
||||
position: relative;
|
||||
min-width: 64px;
|
||||
}
|
||||
.selector__pill--active {
|
||||
background: var(--tk-card);
|
||||
box-shadow: var(--tk-shadow-md);
|
||||
}
|
||||
.selector__pill--done::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: var(--tk-acc);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.selector__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
transition: transform var(--tk-duration-normal) var(--tk-easing);
|
||||
}
|
||||
.selector__pill--active .selector__icon {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.selector__label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-tx3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.selector__pill--active .selector__label {
|
||||
color: var(--tk-tx);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 仪表盘区域 */
|
||||
.gauge-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-md) 0 var(--tk-gap-lg);
|
||||
}
|
||||
.gauge {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
.gauge__ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--tk-bd);
|
||||
stroke-width: 10;
|
||||
}
|
||||
.gauge__ring-progress {
|
||||
fill: none;
|
||||
stroke-width: 10;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.4s ease-out;
|
||||
}
|
||||
.gauge__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.gauge__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: var(--tk-tx);
|
||||
line-height: 1;
|
||||
}
|
||||
.gauge__unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: var(--tk-tx3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 测量中脉冲动画 */
|
||||
.gauge--measuring {
|
||||
animation: gauge-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gauge-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
/* 测量进度条 */
|
||||
.progress-bar {
|
||||
width: 240px;
|
||||
height: 4px;
|
||||
background: var(--tk-bd);
|
||||
border-radius: 2px;
|
||||
margin-top: var(--tk-gap-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 健康评估标签 */
|
||||
.assessment {
|
||||
margin-top: var(--tk-gap-md);
|
||||
padding: 8px 20px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.assessment--normal {
|
||||
background: var(--tk-acc-l);
|
||||
color: var(--tk-acc);
|
||||
}
|
||||
.assessment--warning {
|
||||
background: var(--tk-wrn-l);
|
||||
color: var(--tk-wrn);
|
||||
}
|
||||
.assessment--danger {
|
||||
background: var(--tk-dan-l);
|
||||
color: var(--tk-dan);
|
||||
}
|
||||
|
||||
/* 免责声明 */
|
||||
.disclaimer {
|
||||
text-align: center;
|
||||
padding: 0 var(--tk-page-padding);
|
||||
margin: var(--tk-gap-sm) 0 var(--tk-gap-lg);
|
||||
}
|
||||
.disclaimer__text {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-tx3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.actions {
|
||||
padding: 0 var(--tk-page-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
.btn {
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: opacity var(--tk-duration-fast);
|
||||
}
|
||||
.btn:active { opacity: 0.85; }
|
||||
.btn--primary {
|
||||
background: var(--tk-pri);
|
||||
color: #fff;
|
||||
box-shadow: var(--tk-shadow-btn);
|
||||
}
|
||||
.btn--secondary {
|
||||
background: var(--tk-card);
|
||||
color: var(--tk-tx);
|
||||
border: 1px solid var(--tk-bd);
|
||||
}
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--tk-tx3);
|
||||
height: 44px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
页面 2 — 数据上传页面
|
||||
═══════════════════════════════════════ */
|
||||
.upload-page {
|
||||
background: var(--tk-bg);
|
||||
min-height: 100%;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 页面标题区 */
|
||||
.page-header {
|
||||
padding: var(--tk-gap-lg) var(--tk-page-padding) var(--tk-gap-md);
|
||||
}
|
||||
.page-header__title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 700;
|
||||
color: var(--tk-tx);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.page-header__subtitle {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: var(--tk-tx3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 结果卡片网格 */
|
||||
.results-grid {
|
||||
padding: 0 var(--tk-page-padding);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
.result-card {
|
||||
background: var(--tk-card);
|
||||
border-radius: var(--tk-card-radius);
|
||||
padding: var(--tk-gap-md);
|
||||
box-shadow: var(--tk-shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.result-card--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.result-card__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.result-card__label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-tx2);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.result-card__value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.result-card__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: var(--tk-tx);
|
||||
line-height: 1;
|
||||
}
|
||||
.result-card__unit {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-tx3);
|
||||
}
|
||||
.result-card__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 500;
|
||||
}
|
||||
.result-card__tag--normal {
|
||||
background: var(--tk-acc-l);
|
||||
color: var(--tk-acc);
|
||||
}
|
||||
.result-card__tag--warning {
|
||||
background: var(--tk-wrn-l);
|
||||
color: var(--tk-wrn);
|
||||
}
|
||||
|
||||
/* 未测量占位 */
|
||||
.result-card--empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.result-card__placeholder {
|
||||
padding-left: 8px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: var(--tk-tx3);
|
||||
}
|
||||
|
||||
/* 底部上传播区 */
|
||||
.upload-footer {
|
||||
padding: var(--tk-gap-lg) var(--tk-page-padding) var(--tk-gap-xl);
|
||||
}
|
||||
.upload-footer__hint {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-tx3);
|
||||
text-align: center;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
.upload-footer__time {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-tx3);
|
||||
text-align: center;
|
||||
margin-top: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
长者模式覆盖
|
||||
═══════════════════════════════════════ */
|
||||
.elder-mode {
|
||||
--tk-font-h2: 25px;
|
||||
--tk-font-body-lg: 22px;
|
||||
--tk-font-body: 22px;
|
||||
--tk-font-body-sm: 19px;
|
||||
--tk-font-num: 34px;
|
||||
--tk-font-num-lg: 40px;
|
||||
--tk-font-cap: 18px;
|
||||
--tk-font-micro: 17px;
|
||||
}
|
||||
.elder-mode .selector {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
.elder-mode .selector__pill {
|
||||
min-width: 80px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.elder-mode .gauge {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
}
|
||||
.elder-mode .gauge__value {
|
||||
font-size: 64px;
|
||||
}
|
||||
.elder-mode .results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.elder-mode .btn {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
状态切换按钮(原型交互用)
|
||||
═══════════════════════════════════════ */
|
||||
.state-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px var(--tk-page-padding);
|
||||
background: var(--tk-card);
|
||||
border-top: 1px solid var(--tk-bd);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
.state-btn {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--tk-bd);
|
||||
background: var(--tk-bg);
|
||||
font-size: 12px;
|
||||
color: var(--tk-tx2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.state-btn--active {
|
||||
background: var(--tk-pri);
|
||||
color: #fff;
|
||||
border-color: var(--tk-pri);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
页面 1 — 测量页面(就绪态,正在测量心率)
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="phone-label">页面 1 · 测量页(就绪态 — 心率测量中)</div>
|
||||
<div class="phone">
|
||||
<div class="phone-inner">
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar">
|
||||
<span>9:41</span>
|
||||
<div class="status-bar-right">
|
||||
<span>●●●●</span>
|
||||
<span>WiFi</span>
|
||||
<span>85%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<div class="device-bar">
|
||||
<div class="device-bar__left">
|
||||
<div class="device-bar__dot"></div>
|
||||
<span class="device-bar__name">M2 手环</span>
|
||||
<span class="device-bar__battery">85%</span>
|
||||
</div>
|
||||
<button class="device-bar__disconnect">断开</button>
|
||||
</div>
|
||||
|
||||
<!-- 5 指标选择器 -->
|
||||
<div class="selector">
|
||||
<!-- 心率(选中 + 已完成) -->
|
||||
<div class="selector__pill selector__pill--active selector__pill--done">
|
||||
<div class="selector__icon" style="background: #EF4444;">♥</div>
|
||||
<span class="selector__label">心率</span>
|
||||
</div>
|
||||
<!-- 血氧(已完成) -->
|
||||
<div class="selector__pill selector__pill--done">
|
||||
<div class="selector__icon" style="background: #3B82F6;">O₂</div>
|
||||
<span class="selector__label">血氧</span>
|
||||
</div>
|
||||
<!-- 血压 -->
|
||||
<div class="selector__pill">
|
||||
<div class="selector__icon" style="background: #8B5CF6;">↕</div>
|
||||
<span class="selector__label">血压</span>
|
||||
</div>
|
||||
<!-- 体温 -->
|
||||
<div class="selector__pill">
|
||||
<div class="selector__icon" style="background: #F59E0B;">T</div>
|
||||
<span class="selector__label">体温</span>
|
||||
</div>
|
||||
<!-- 压力 -->
|
||||
<div class="selector__pill">
|
||||
<div class="selector__icon" style="background: #6366F1;">~</div>
|
||||
<span class="selector__label">压力</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 仪表盘 -->
|
||||
<div class="gauge-section">
|
||||
<div class="gauge gauge--measuring">
|
||||
<svg width="220" height="220" viewBox="0 0 220 220">
|
||||
<circle cx="110" cy="110" r="100" class="gauge__ring-bg"/>
|
||||
<circle cx="110" cy="110" r="100" class="gauge__ring-progress"
|
||||
stroke="#EF4444"
|
||||
stroke-dasharray="471"
|
||||
stroke-dashoffset="141"
|
||||
transform="rotate(-90 110 110)"/>
|
||||
</svg>
|
||||
<div class="gauge__center">
|
||||
<span class="gauge__value" style="color: #EF4444;">72</span>
|
||||
<span class="gauge__unit">bpm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar__fill" style="width: 70%; background: #EF4444;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 评估标签 -->
|
||||
<div class="assessment assessment--normal">♥ 心率正常</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="disclaimer">
|
||||
<p class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="actions">
|
||||
<button class="btn btn--primary" style="background: #EF4444; box-shadow: 0 4px 16px rgba(239,68,68,0.3);">
|
||||
停止测量
|
||||
</button>
|
||||
<button class="btn btn--secondary">
|
||||
完成并查看结果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
页面 2 — 数据上传页面(测量结果汇总)
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="phone-label">页面 2 · 数据上传页(结果汇总 + 上传)</div>
|
||||
<div class="phone">
|
||||
<div class="phone-inner">
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar">
|
||||
<span>9:41</span>
|
||||
<div class="status-bar-right">
|
||||
<span>●●●●</span>
|
||||
<span>WiFi</span>
|
||||
<span>85%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-header__title">测量结果</h1>
|
||||
<p class="page-header__subtitle">Veepoo M2 · 刚刚完成测量</p>
|
||||
</div>
|
||||
|
||||
<!-- 结果卡片网格 -->
|
||||
<div class="results-grid">
|
||||
<!-- 心率 — 已测量,正常 -->
|
||||
<div class="result-card">
|
||||
<div class="result-card__badge" style="background: #EF4444;"></div>
|
||||
<div class="result-card__label">心率</div>
|
||||
<div class="result-card__value-row">
|
||||
<span class="result-card__value">72</span>
|
||||
<span class="result-card__unit">bpm</span>
|
||||
</div>
|
||||
<div class="result-card__tag result-card__tag--normal">● 正常</div>
|
||||
</div>
|
||||
|
||||
<!-- 血氧 — 已测量,正常 -->
|
||||
<div class="result-card">
|
||||
<div class="result-card__badge" style="background: #3B82F6;"></div>
|
||||
<div class="result-card__label">血氧</div>
|
||||
<div class="result-card__value-row">
|
||||
<span class="result-card__value">98</span>
|
||||
<span class="result-card__unit">%</span>
|
||||
</div>
|
||||
<div class="result-card__tag result-card__tag--normal">● 正常</div>
|
||||
</div>
|
||||
|
||||
<!-- 血压 — 已测量,注意 -->
|
||||
<div class="result-card result-card--full">
|
||||
<div class="result-card__badge" style="background: #8B5CF6;"></div>
|
||||
<div class="result-card__label">血压</div>
|
||||
<div class="result-card__value-row">
|
||||
<span class="result-card__value">135</span>
|
||||
<span class="result-card__unit">/ 88 mmHg</span>
|
||||
</div>
|
||||
<div class="result-card__tag result-card__tag--warning">● 偏高</div>
|
||||
</div>
|
||||
|
||||
<!-- 体温 — 未测量 -->
|
||||
<div class="result-card result-card--empty">
|
||||
<div class="result-card__badge" style="background: #F59E0B; opacity: 0.3;"></div>
|
||||
<div class="result-card__label">体温</div>
|
||||
<div class="result-card__placeholder">未测量</div>
|
||||
</div>
|
||||
|
||||
<!-- 压力 — 未测量 -->
|
||||
<div class="result-card result-card--empty">
|
||||
<div class="result-card__badge" style="background: #6366F1; opacity: 0.3;"></div>
|
||||
<div class="result-card__label">压力</div>
|
||||
<div class="result-card__placeholder">未测量</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部上传播区 -->
|
||||
<div class="upload-footer">
|
||||
<p class="upload-footer__hint">测量数据将上传至您的健康档案</p>
|
||||
<button class="btn btn--primary">
|
||||
上传测量数据(3 项)
|
||||
</button>
|
||||
<p class="upload-footer__time">测量时间:2026-05-30 14:35</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
页面 3 — 测量页面(未连接态)
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="phone-label">页面 1 · 测量页(未连接态)</div>
|
||||
<div class="phone">
|
||||
<div class="phone-inner">
|
||||
<div class="status-bar">
|
||||
<span>9:41</span>
|
||||
<div class="status-bar-right">
|
||||
<span>●●●●</span>
|
||||
<span>WiFi</span>
|
||||
<span>85%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="measure-page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 720px;">
|
||||
<!-- 蓝牙脉冲动画 -->
|
||||
<div style="position: relative; width: 120px; height: 120px; margin-bottom: 28px;">
|
||||
<div style="position: absolute; inset: 0; border-radius: 50%; border: 3px solid var(--tk-pri); animation: pulse-ring 2s ease-out infinite;"></div>
|
||||
<div style="position: absolute; inset: 20px; border-radius: 50%; background: var(--tk-pri); display: flex; align-items: center; justify-content: center;">
|
||||
<span style="color: #fff; font-size: 20px; font-weight: 700;">BT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="font-family: Georgia, serif; font-size: var(--tk-font-h2); font-weight: 700; color: var(--tk-tx); margin-bottom: 8px;">
|
||||
M2 手环健康测量
|
||||
</h2>
|
||||
<p style="font-size: var(--tk-font-body-sm); color: var(--tk-tx3); margin-bottom: 32px; text-align: center; padding: 0 40px;">
|
||||
请确保手环已佩戴且蓝牙已开启
|
||||
</p>
|
||||
|
||||
<button class="btn btn--primary" style="width: 200px;">
|
||||
连接 M2 手环
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
页面 4 — 测量页面(血压已测量完成态)
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="phone-label">页面 1 · 测量页(血压测量完成)</div>
|
||||
<div class="phone">
|
||||
<div class="phone-inner">
|
||||
<div class="status-bar">
|
||||
<span>9:42</span>
|
||||
<div class="status-bar-right">
|
||||
<span>●●●●</span>
|
||||
<span>WiFi</span>
|
||||
<span>85%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="measure-page">
|
||||
<div class="device-bar">
|
||||
<div class="device-bar__left">
|
||||
<div class="device-bar__dot"></div>
|
||||
<span class="device-bar__name">M2 手环</span>
|
||||
<span class="device-bar__battery">85%</span>
|
||||
</div>
|
||||
<button class="device-bar__disconnect">断开</button>
|
||||
</div>
|
||||
|
||||
<!-- 5 指标选择器 — 血压选中 -->
|
||||
<div class="selector">
|
||||
<div class="selector__pill selector__pill--done">
|
||||
<div class="selector__icon" style="background: #EF4444;">♥</div>
|
||||
<span class="selector__label">心率</span>
|
||||
</div>
|
||||
<div class="selector__pill selector__pill--done">
|
||||
<div class="selector__icon" style="background: #3B82F6;">O₂</div>
|
||||
<span class="selector__label">血氧</span>
|
||||
</div>
|
||||
<div class="selector__pill selector__pill--active selector__pill--done">
|
||||
<div class="selector__icon" style="background: #8B5CF6;">↕</div>
|
||||
<span class="selector__label">血压</span>
|
||||
</div>
|
||||
<div class="selector__pill">
|
||||
<div class="selector__icon" style="background: #F59E0B;">T</div>
|
||||
<span class="selector__label">体温</span>
|
||||
</div>
|
||||
<div class="selector__pill">
|
||||
<div class="selector__icon" style="background: #6366F1;">~</div>
|
||||
<span class="selector__label">压力</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 仪表盘 — 血压完成态 -->
|
||||
<div class="gauge-section">
|
||||
<div class="gauge">
|
||||
<svg width="220" height="220" viewBox="0 0 220 220">
|
||||
<circle cx="110" cy="110" r="100" class="gauge__ring-bg"/>
|
||||
<circle cx="110" cy="110" r="100" class="gauge__ring-progress"
|
||||
stroke="#8B5CF6"
|
||||
stroke-dasharray="471"
|
||||
stroke-dashoffset="0"
|
||||
transform="rotate(-90 110 110)"/>
|
||||
</svg>
|
||||
<div class="gauge__center">
|
||||
<span class="gauge__value" style="color: #8B5CF6;">135<span style="font-size: 24px; color: var(--tk-tx3);">/</span>88</span>
|
||||
<span class="gauge__unit">mmHg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评估标签 — 偏高 -->
|
||||
<div class="assessment assessment--warning">↕ 血压偏高,建议关注</div>
|
||||
</div>
|
||||
|
||||
<div class="disclaimer">
|
||||
<p class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn--primary" style="background: #8B5CF6; box-shadow: 0 4px 16px rgba(139,92,246,0.3);">
|
||||
重新测量
|
||||
</button>
|
||||
<button class="btn btn--secondary">
|
||||
完成并查看结果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/design/veepoo-prototype-preview.png
Normal file
BIN
docs/design/veepoo-prototype-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
287
docs/discussions/2026-05-28-six-dimension-deep-analysis.md
Normal file
287
docs/discussions/2026-05-28-six-dimension-deep-analysis.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# HMS 六维度深度分析 — 多专家组头脑风暴会议纪要
|
||||
|
||||
> 日期: 2026-05-28 | 分支: feat/media-library-banner | 方法: 6 并行专家组独立分析 + 综合交叉验证
|
||||
> 前序分析: 2026-05-20 V1 就绪度(6.3) / 2026-05-17 六维度均衡(6.8) / 2026-05-11 全面分析(7.0)
|
||||
|
||||
## 综合评分: 5.8 / 10 (C+)
|
||||
|
||||
> 较 2026-05-20 的 6.3 分下降,原因是本次分析深度显著增加,暴露了更多隐藏问题(审计日志 PII 泄漏、Redis 明文传输、Handler 层 4.5% 覆盖率等)。评分下调反映的是认知深化而非系统退化。
|
||||
|
||||
| 维度 | 评分 | 趋势 | 专家组 | 核心一句话 |
|
||||
|------|------|------|--------|-----------|
|
||||
| **架构** | **6.7** | → | 首席架构师 | 模块边界 8.5 是最强资产,缓存 4.0 是最弱环节 |
|
||||
| **安全** | **7.2** | → | 首席安全官 | PII 加密企业级 9/10,DB/Redis 明文传输是硬伤 |
|
||||
| **产品** | **6.7** | → | 产品总监 | 工程能力远超产品化程度,AI 后端被困在"看不见"状态 |
|
||||
| **DevOps** | **3.4** | ↓ | DevOps 总监 | CI/CD 零分,Redis 密码公网明文,灾备能力近乎为零 |
|
||||
| **测试** | **4.5** | ↓ | 质量总监 | Handler 层 4.5% 覆盖率是最大盲区,小程序测试接近于零 |
|
||||
| **AI** | **6.0** | → | AI 架构师 | Agent 能力 7.5 但 Token 计量为 0,前端入口全部缺失 |
|
||||
|
||||
---
|
||||
|
||||
## 一、各维度关键发现
|
||||
|
||||
### 1. 架构 (6.7/10 B)
|
||||
|
||||
**最强点:**
|
||||
- L2 模块间零直接依赖已真正实现(grep 验证)
|
||||
- Outbox 模式三阶段(持久化→广播→更新+NOTIFY)是生产级质量
|
||||
- ErpModule trait 天然支持微服务拆分
|
||||
|
||||
**最弱点:**
|
||||
- 业务数据缓存几乎为零(仅 Moka 插件缓存 + Redis 限流),每次 API 至少 3 次 DB 查询
|
||||
- 173 个迁移文件管理成本失控(仅 5/20-22 就产生 12 个)
|
||||
- erp-health module.rs 单文件 916 行(路由+定时任务+权限+生命周期)
|
||||
|
||||
### 2. 安全 (7.2/10 B+)
|
||||
|
||||
**最强点:**
|
||||
- PII 加密(AES-256-GCM + KEK/DEK + HMAC 盲索引)达企业级 9/10
|
||||
- API 安全(5 层限流 + 文件上传白名单 + CORS 拒绝通配符)9/10
|
||||
- 审计日志 SHA-256 哈希链完整性验证
|
||||
|
||||
**最弱点:**
|
||||
- **PostgreSQL 和 Redis 连接均无 TLS** — 凭据和数据在网络上明文传输
|
||||
- **审计日志 old_value/new_value 可能包含 PII 明文** — 数据库被入侵后审计表成为泄漏源
|
||||
- **patient.name 明文存储** — 等保三级要求姓名属于敏感信息
|
||||
- **JWT 使用 HS256 对称密钥** — 泄漏等于全系统接管
|
||||
- **X-Forwarded-For 直接信任** — IP 伪造可绕过速率限制
|
||||
|
||||
### 3. 产品 (6.7/10 B)
|
||||
|
||||
**最强点:**
|
||||
- 患者全生命周期主干链路已闭环(约 85% 完整度)
|
||||
- 竞品差异化优势明显(AI 深度 + Rust 全栈 + BLE 设备 + 适老化)
|
||||
- 长者模式 58/58 页面 100% 覆盖是刚需壁垒
|
||||
|
||||
**最弱点:**
|
||||
- **4 个 SSE AI 分析端点无前端 UI 触发入口** — 最大产品断裂
|
||||
- **6 个冻结模块**(关怀计划/透析/用药等)有后端无前端
|
||||
- **小程序 4 个域完全无入口**(告警/透析/知情同意/AI)
|
||||
- **商业化路径不清晰** — 无用量计费基础设施,积分商城无核销闭环
|
||||
|
||||
### 4. DevOps (3.4/10 D+)
|
||||
|
||||
**最强点:**
|
||||
- Docker 配置文件质量高(三阶段构建 + 资源限制 + 健康检查)
|
||||
- 安全基础设施配置到位(Nginx TLS + HSTS + CSP + 备份加密)
|
||||
|
||||
**最弱点:**
|
||||
- **CI/CD 评分 1/10** — 零自动化,所有质量关卡人工操作
|
||||
- **灾难恢复 1.5/10** — 无 RTO/RPO 定义,备份仅本地无异地
|
||||
- **数据库运维 2/10** — 单实例无 HA,连接池 max_connections=20 偏小
|
||||
- **Redis 密码 `<REDACTED>` 通过公网明文传输到腾讯云**(2026-06-25 已轮换 + 仓库明文已清除)
|
||||
- **监控"配置齐全、运行为零"** — Prometheus 10 条告警规则从未实际运行
|
||||
|
||||
### 5. 测试 (4.5/10 D+)
|
||||
|
||||
**最强点:**
|
||||
- CI 流水线结构合理(三平台并行执行)
|
||||
- erp-server 167 个集成测试是系统中测试质量最高的部分
|
||||
- Clippy 全 workspace 0 警告
|
||||
|
||||
**最弱点:**
|
||||
- **Handler 层覆盖率 4.5%**(66 文件中仅 3 个有测试)
|
||||
- **Middleware 层覆盖率 0%** — 多租户隔离无自动化回归验证
|
||||
- **小程序 src 目录下 0 个测试文件** — 192 个源文件中 6% 覆盖率
|
||||
- **性能测试完全空白** — 无 criterion/k6/locust
|
||||
- **E2E 测试不在 CI 中** — 17 个 spec 全靠手动执行
|
||||
|
||||
### 6. AI (6.0/10 B-)
|
||||
|
||||
**最强点:**
|
||||
- ReAct Agent 完整实现(9 工具 + Token 预算 + 角色沙箱)
|
||||
- 7 种 DisplayHint 富展示类型设计前瞻
|
||||
- PII 脱敏双重保障(SanitizationService + HealthDataProvider)
|
||||
|
||||
**最弱点:**
|
||||
- **Token 计量记录为 0**(chat_handler 第 310 行硬编码 0) — 成本控制形同虚设
|
||||
- **display_hints 被丢弃**(chat_handler 第 362 行写死 None) — 前端 RichMessage 组件就绪但收不到数据
|
||||
- **Ollama Function Calling 未实现** — 本地部署时 Agent 退化为纯对话
|
||||
- **RAG 纯向量搜索无混合检索** — 医疗术语精确匹配不够
|
||||
|
||||
---
|
||||
|
||||
## 二、跨维度交叉发现
|
||||
|
||||
> 以下问题是多个专家组独立发现的同一根因,说明是系统性问题而非局部缺陷。
|
||||
|
||||
### 交叉问题 1: AI 能力"有后无前"(6 个专家中 4 个独立发现)
|
||||
|
||||
| 专家 | 表述 |
|
||||
|------|------|
|
||||
| AI 架构师 | "4 个 SSE 端点无前端 UI 触发入口" |
|
||||
| 产品总监 | "工程能力远超产品化程度,后端投入大量资源但只有 AI 对话一个入口对用户可见" |
|
||||
| 架构师 | "知识库 V2 的 RAG 能力只用于 ChatPage 通用对话,未嵌入业务场景" |
|
||||
| 质量总监 | "AI 模块 206 个测试中绝大多数是数据结构和序列化测试" |
|
||||
|
||||
**根因**: AI 模块按后端优先策略开发,前端对接计划滞后。这是 ROI 最高的修复点。
|
||||
|
||||
### 交叉问题 2: 数据传输安全缺口(安全 + DevOps 独立发现)
|
||||
|
||||
| 专家 | 表述 |
|
||||
|------|------|
|
||||
| 安全官 | "PostgreSQL 和 Redis 连接均无 TLS,凭据在网络上明文传输" |
|
||||
| DevOps | "Redis 密码通过公网明文传输到云端 Redis(IP 已 redact;该实例闲置未被 HMS 使用)" |
|
||||
|
||||
**根因**: 开发环境便捷性优先,安全配置被推迟。修复成本极低(1-2 天),影响极高。
|
||||
|
||||
### 交叉问题 3: 测试盲区集中在安全关键路径(安全 + 质量 独立发现)
|
||||
|
||||
| 专家 | 表述 |
|
||||
|------|------|
|
||||
| 安全官 | "无权限绕过测试、无 SQL 注入测试、无跨租户数据泄漏测试" |
|
||||
| 质量总监 | "Handler 层 4.5% 覆盖率、Middleware 0%、小程序 service 层 0 测试" |
|
||||
|
||||
**根因**: TDD 流程在 handler/middleware 层未执行。历史数据显示 24% 的提交是 fix,大部分可在合并前被 CI 拦截。
|
||||
|
||||
---
|
||||
|
||||
## 三、风险矩阵
|
||||
|
||||
按 **影响×概率** 排序的 TOP 10 风险:
|
||||
|
||||
| # | 风险 | 影响 | 概率 | 维度 | 行动 |
|
||||
|---|------|------|------|------|------|
|
||||
| 1 | Redis 密码公网明文传输 | 致命 | 已发生 | 安全+DevOps | 启用 TLS(1天) |
|
||||
| 2 | 数据库单点故障无 HA | 致命 | 中 | DevOps | 流复制+热备(3天) |
|
||||
| 3 | 备份无异地存储 | 致命 | 低 | DevOps | S3/OSS 上传(1天) |
|
||||
| 4 | 审计日志含 PII 明文 | 高 | 已发生 | 安全 | 脱敏处理(2天) |
|
||||
| 5 | Handler 层 4.5% 测试覆盖率 | 高 | 高 | 质量 | 权限+验证测试(5天) |
|
||||
| 6 | AI Token 计量为 0 | 高 | 已发生 | AI | 从 Provider 提取(1天) |
|
||||
| 7 | JWT HS256 对称密钥 | 高 | 低 | 安全 | 迁移 RS256(5天) |
|
||||
| 8 | 缓存层空白 | 中 | 高 | 架构 | Redis+Moka 缓存(5天) |
|
||||
| 9 | AI 前端入口缺失 | 中 | 已发生 | 产品+AI | 4 个业务页面嵌入(5天) |
|
||||
| 10 | CI/CD 零自动化 | 中 | 高 | DevOps+质量 | GitHub Actions(3天) |
|
||||
|
||||
---
|
||||
|
||||
## 四、专家组头脑风暴 — 争议与共识
|
||||
|
||||
### 共识(6/6 专家一致)
|
||||
|
||||
1. **AI 产品化是最大杠杆点** — 后端能力已构建但用户无法感知,投入产出比最高
|
||||
2. **DevOps 是最短木板** — CI/CD + 灾备 + 监控三个维度都在 D 级,是上线的硬阻塞
|
||||
3. **安全基础设施已到位但自动化不足** — TLS/密钥轮换/依赖扫描都需自动化
|
||||
4. **测试覆盖需要聚焦在安全关键路径** — Handler + Middleware + 多租户隔离
|
||||
|
||||
### 争议
|
||||
|
||||
1. **架构师 vs DevOps: 优先级分歧**
|
||||
- 架构师认为缓存层(5天)是 ROI 最高的架构改进
|
||||
- DevOps 认为 Redis TLS(1天)和 CI(3天)是生存优先
|
||||
- **结论**: DevOps P0 项(TLS/CI/备份)先做,缓存层紧随其后
|
||||
|
||||
2. **产品 vs 安全: AI 免责声明时机**
|
||||
- 产品认为 AI 前端入口可以和免责声明同步上线
|
||||
- 安全认为必须先有免责声明和人工确认流程才能开放 AI 入口
|
||||
- **结论**: 安全优先 — 先实现免责声明(1天),再开放 AI 入口
|
||||
|
||||
3. **质量 vs 产品: 冻结模块处理策略**
|
||||
- 质量认为冻结模块(有后端无前端)应先补测试再解冻
|
||||
- 产品认为关怀计划和透析是核心业务,应尽快解冻交付
|
||||
- **结论**: 关怀计划优先解冻(已有 handler + 权限码),透析等待测试补齐后解冻
|
||||
|
||||
---
|
||||
|
||||
## 五、行动路线图
|
||||
|
||||
### Phase 0: 生存保障(1-2 周,P0 阻塞项)
|
||||
|
||||
> 目标: 消除致命风险,建立基本运维能力
|
||||
|
||||
| # | 行动 | 负责维度 | 工作量 | 风险消除 |
|
||||
|---|------|---------|--------|---------|
|
||||
| 0.1 | Redis + PostgreSQL 连接强制 TLS | 安全+DevOps | 2天 | 公网明文传输 |
|
||||
| 0.2 | GitHub Actions CI 流水线 | DevOps+质量 | 3天 | 代码质量零门禁 |
|
||||
| 0.3 | 备份异地存储(S3/OSS)+ 恢复演练 | DevOps | 2天 | 灾难时数据永久丢失 |
|
||||
| 0.4 | 审计日志 PII 脱敏 | 安全 | 2天 | 审计表成为泄漏源 |
|
||||
| 0.5 | Prometheus + Grafana + 告警通知上线 | DevOps | 2天 | 生产环境"盲飞" |
|
||||
| 0.6 | AI Token 计量修复 + display_hints 传递 | AI | 1天 | 成本控制失效 |
|
||||
|
||||
### Phase 1: 产品释放(2-3 周,用户价值释放)
|
||||
|
||||
> 目标: 把已建好的后端能力通过前端释放给用户
|
||||
|
||||
| # | 行动 | 负责维度 | 工作量 |
|
||||
|---|------|---------|--------|
|
||||
| 1.1 | AI 分析嵌入 4 个业务页面 | 产品+AI | 5天 |
|
||||
| 1.2 | AI 免责声明 + 人工确认流程 | 安全+产品 | 2天 |
|
||||
| 1.3 | 知识库 V2 化验场景化接入 | AI | 3天 |
|
||||
| 1.4 | 关怀计划解冻 + AI 建议→关怀计划 | 产品 | 3天 |
|
||||
| 1.5 | 小程序补齐告警/AI 入口 | 产品 | 3天 |
|
||||
| 1.6 | 业务数据缓存层(字典/菜单/权限/患者列表) | 架构 | 5天 |
|
||||
|
||||
### Phase 2: 安全加固(2-3 周,合规底线)
|
||||
|
||||
> 目标: 满足医疗数据合规和等保三级基本要求
|
||||
|
||||
| # | 行动 | 负责维度 | 工作量 |
|
||||
|---|------|---------|--------|
|
||||
| 2.1 | 患者姓名加密存储 + name_hash 盲索引 | 安全 | 5天 |
|
||||
| 2.2 | JWT 迁移 RS256 + Trusted Proxy 配置 | 安全 | 5天 |
|
||||
| 2.3 | cargo-deny + npm audit CI 集成 | 安全+DevOps | 2天 |
|
||||
| 2.4 | 患者数据导出 API + 数据留存策略 | 安全+产品 | 5天 |
|
||||
| 2.5 | ICD-10 编码校验 + 诊断标准化 | 产品 | 3天 |
|
||||
|
||||
### Phase 3: 质量提升(2-3 周,回归保障)
|
||||
|
||||
> 目标: 关键路径测试覆盖率达到 70%+
|
||||
|
||||
| # | 行动 | 负责维度 | 工作量 |
|
||||
|---|------|---------|--------|
|
||||
| 3.1 | Handler 层关键路径测试(权限 403 + 验证 422) | 质量 | 5天 |
|
||||
| 3.2 | Middleware 测试(tenant_id/frozen/security_headers) | 质量 | 2天 |
|
||||
| 3.3 | 小程序 service 层单元测试(request/storage/auth) | 质量 | 4天 |
|
||||
| 3.4 | 安全测试套件(SQL注入/认证绕过/越权) | 质量+安全 | 3天 |
|
||||
| 3.5 | E2E 扩展 + CI 集成 | 质量 | 3天 |
|
||||
|
||||
---
|
||||
|
||||
## 六、投入产出比分析
|
||||
|
||||
| 行动 | 工作量 | 评分提升预期 | ROI |
|
||||
|------|--------|------------|-----|
|
||||
| Redis/PG TLS | 2天 | 安全 7.2→8.0 | ★★★★★ |
|
||||
| AI 前端入口 | 5天 | 产品 6.7→7.5, AI 6.0→7.0 | ★★★★★ |
|
||||
| CI 流水线 | 3天 | DevOps 3.4→4.5, 质量 4.5→5.5 | ★★★★☆ |
|
||||
| 缓存层 | 5天 | 架构 6.7→7.5 | ★★★★☆ |
|
||||
| Handler 测试 | 5天 | 质量 4.5→5.5 | ★★★☆☆ |
|
||||
| Token 计量 | 1天 | AI 6.0→6.5 | ★★★★★ |
|
||||
| 患者姓名加密 | 5天 | 安全 7.2→7.8 | ★★★☆☆ |
|
||||
| JWT RS256 | 5天 | 安全 7.2→7.6 | ★★☆☆☆ |
|
||||
|
||||
---
|
||||
|
||||
## 七、最终结论
|
||||
|
||||
### 系统画像
|
||||
|
||||
HMS 是一个**工程能力超越产品化程度**的健康管理平台:
|
||||
- **后端架构**(Rust 模块化单体 + 事件驱动 + 多租户)达到医疗 SaaS 优秀水平
|
||||
- **安全基础**(PII 加密 + RBAC + 速率限制)在同类项目中属中上
|
||||
- **AI 能力**(ReAct Agent + RAG + 知识库 V2)后端完整但前端入口缺失
|
||||
- **DevOps**(CI/CD/灾备/监控)是致命短板,需立即修复才能支撑生产部署
|
||||
- **测试质量**(Handler 4.5% + Middleware 0%)是安全回归的隐患
|
||||
|
||||
### 核心建议
|
||||
|
||||
1. **先活下来再活得好** — Phase 0(2 周)消除致命风险,Phase 1-3 逐步提升
|
||||
2. **释放已建能力** — AI 前端入口是 ROI 最高的单项投入(5 天,提升 2 个维度评分)
|
||||
3. **安全不能事后补** — TLS/脱敏/加密是合规底线,不是"锦上添花"
|
||||
4. **测试聚焦安全关键路径** — Handler + Middleware + 多租户隔离,不做"到处撒网"
|
||||
|
||||
### 预期评分变化
|
||||
|
||||
| 维度 | 当前 | Phase 0 后 | Phase 1 后 | 全部完成后 |
|
||||
|------|------|-----------|-----------|-----------|
|
||||
| 架构 | 6.7 | 6.7 | 7.5 | 8.0 |
|
||||
| 安全 | 7.2 | 7.8 | 7.8 | 8.5 |
|
||||
| 产品 | 6.7 | 6.7 | 7.5 | 8.0 |
|
||||
| DevOps | 3.4 | 5.0 | 5.5 | 6.5 |
|
||||
| 测试 | 4.5 | 4.5 | 4.5 | 7.0 |
|
||||
| AI | 6.0 | 6.5 | 7.0 | 7.5 |
|
||||
| **综合** | **5.8** | **6.3** | **6.8** | **7.6** |
|
||||
|
||||
---
|
||||
|
||||
*本报告由 6 个并行专家组独立分析后综合而成,所有发现基于实际代码审查而非推测。*
|
||||
206
docs/discussions/2026-05-30-veepoo-sdk-integration-flow.md
Normal file
206
docs/discussions/2026-05-30-veepoo-sdk-integration-flow.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Veepoo M2 BLE SDK 正确对接流程
|
||||
|
||||
> 日期: 2026-05-30 | 参与者: iven, Claude
|
||||
> 状态: 已验证通过
|
||||
|
||||
## 背景
|
||||
|
||||
Veepoo M2 手环的 BLE SDK(372KB Webpack 打包)在微信小程序中的对接遇到了多层问题。
|
||||
本文档记录**经过实际验证的正确对接流程**和踩过的坑,避免后续开发者重复踩坑。
|
||||
|
||||
## SDK 架构
|
||||
|
||||
```
|
||||
Taro 页面 (veepoo-measure/index.tsx)
|
||||
↓ navigateTo
|
||||
原生分包页面 (pkg-veepoo/index.js + index.wxml)
|
||||
↓ require('./libs/veepoo-sdk')
|
||||
Veepoo SDK (veepoo-sdk.js)
|
||||
↓ wx.* BLE API
|
||||
微信蓝牙底层
|
||||
```
|
||||
|
||||
### 为什么用原生分包而非 Taro?
|
||||
|
||||
1. SDK 是纯 JS(372KB Webpack CommonJS2),不兼容 Taro 编译流程
|
||||
2. SDK 使用全局变量 `veepooBle`/`veepooFeature`/`veepooLogger`,需要 `require()` 直接加载
|
||||
3. 微信小程序 JS 引擎不支持 ES2020 语法(`??`、`?.`),原生页面可精确控制语法
|
||||
4. 分包独立,不影响主包体积
|
||||
|
||||
### 构建集成
|
||||
|
||||
```js
|
||||
// config/index.ts — mini.copy.patterns
|
||||
{ from: 'vendor/veepoo-sdk/libs/vp_sdk/index.js', to: 'dist/pkg-veepoo/libs/veepoo-sdk.js' },
|
||||
{ from: 'native/pkg-veepoo/', to: 'dist/pkg-veepoo/', ignore: ['*.ts'] },
|
||||
```
|
||||
|
||||
**注意**:`dev:weapp` 的 watch 模式不监听 `native/` 目录变化,修改原生页面后需清除 dist 重建:
|
||||
```bash
|
||||
rm -rf apps/miniprogram/dist/pkg-veepoo
|
||||
pnpm run build:weapp # 或 pnpm run dev:weapp
|
||||
```
|
||||
|
||||
## 正确对接流程(已验证)
|
||||
|
||||
### 1. 页面加载(onLoad)
|
||||
|
||||
```js
|
||||
onLoad: function () {
|
||||
this._eventChannel = this.getOpenerEventChannel();
|
||||
// ❌ 不要在这里注册 veepooWeiXinSDKNotifyMonitorValueChange!
|
||||
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange
|
||||
// 此时蓝牙适配器未初始化 → 返回 "not init" 错误
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 扫描
|
||||
|
||||
```js
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
|
||||
var name = (device.localName || device.name || '').toUpperCase();
|
||||
// 匹配条件要放宽:M2 / VPM / VEEPOO
|
||||
if (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1) {
|
||||
// 找到设备
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 停止扫描 → 连接
|
||||
|
||||
```js
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, callback);
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 连接回调(关键!)
|
||||
|
||||
**连接回调触发 4 次**,每个阶段一次:
|
||||
|
||||
| # | 回调内容 | 含义 |
|
||||
|---|---------|------|
|
||||
| 1 | `{errno:0, errMsg:"createBLEConnection:ok"}` | BLE TCP 连接建立 |
|
||||
| 2 | `[{uuid:...}, ...]` | 服务发现完成 |
|
||||
| 3 | `{characteristics:[...], errno:0}` | 特征值发现完成 |
|
||||
| 4 | `{connection:true, deviceId:"..."}` | **特征值订阅完成,通道就绪** |
|
||||
|
||||
**只响应第 4 次 `connection:true`**:
|
||||
|
||||
```js
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
|
||||
// ❌ 不要用 errno===0 匹配!第 1 次回调就满足,但此时订阅未完成
|
||||
// ✅ 只匹配 connection:true(第 4 次回调)
|
||||
if (result.connection === true) {
|
||||
// 此时 BLE 通道完全就绪
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 5. 注册数据监听器(在 connection:true 回调内)
|
||||
|
||||
```js
|
||||
if (result.connection === true) {
|
||||
// ✅ 此时蓝牙适配器已初始化 + 连接已建立 + 特征值已订阅
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
|
||||
// data.type 对应不同事件:1=认证, 2=电量, 6=体温, 18=血压, 31=血氧, 51=心率, 58=压力
|
||||
handleSdkEvent(data);
|
||||
});
|
||||
|
||||
// 同时注册连接状态变化监听
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
|
||||
if (!res.connected) { /* 断开处理 */ }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 认证(连接就绪后延迟 500ms)
|
||||
|
||||
```js
|
||||
setTimeout(function () {
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
}, 500);
|
||||
```
|
||||
|
||||
### 7. 认证结果判断(关键!)
|
||||
|
||||
```js
|
||||
// SDK 认证回调结构:
|
||||
// {
|
||||
// type: 1,
|
||||
// content: {
|
||||
// VPDevicepassword: "0000", ← 设备密码原始值
|
||||
// VPDeviceAck: "successfulVerification", ← ✅ 认证结果
|
||||
// VPDeviceVersion: "01.63.01.00-7466",
|
||||
// VPDeviceMAC: "BC:92:DC:9F:CA:6A",
|
||||
// ...
|
||||
// }
|
||||
// }
|
||||
|
||||
// ❌ 错误:检查 VPDevicepassword(值是 "0000",永远不匹配)
|
||||
if (content.VPDevicepassword === 'successfulVerification') { ... }
|
||||
|
||||
// ✅ 正确:检查 VPDeviceAck
|
||||
if (content.VPDeviceAck === 'successfulVerification' ||
|
||||
content.VPDeviceAck === 'passTheVerification') {
|
||||
// 认证成功
|
||||
}
|
||||
```
|
||||
|
||||
**VPDeviceAck 可能的值**:
|
||||
- `successfulVerification` — 密码和时间校验成功
|
||||
- `passTheVerification` — 核验通过
|
||||
- `verifyNotPass` — 核验不通过
|
||||
- `setupFailed` — 设置不成功
|
||||
|
||||
### 8. Storage 轮询兜底
|
||||
|
||||
SDK 会写入 `deviceChipStatus` 到 Storage,但**可能是布尔值 `true` 而非字符串**:
|
||||
|
||||
```js
|
||||
var status = wx.getStorageSync('deviceChipStatus');
|
||||
// ✅ 同时匹配字符串和布尔值
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
|
||||
// 认证成功
|
||||
}
|
||||
```
|
||||
|
||||
## 完整流程图
|
||||
|
||||
```
|
||||
onLoad → 扫描 → 找到M2 → 停止扫描
|
||||
↓
|
||||
连接(veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager)
|
||||
→ 回调1(errno:0) → 忽略
|
||||
→ 回调2(services) → 忽略
|
||||
→ 回调3(characteristics) → 忽略
|
||||
→ 回调4(connection:true) →
|
||||
① 注册数据监听器(veepooWeiXinSDKNotifyMonitorValueChange)
|
||||
② 注册连接状态监听器
|
||||
③ 延迟500ms → 调用认证(veepooBlePasswordCheckManager)
|
||||
④ 启动 Storage 轮询(deviceChipStatus) + 8s 超时
|
||||
↓
|
||||
数据监听器收到 type=1 事件 → 检查 VPDeviceAck === "successfulVerification"
|
||||
↓
|
||||
认证成功 → 设备就绪 → 可开始测量
|
||||
```
|
||||
|
||||
## 踩坑清单
|
||||
|
||||
| # | 问题 | 根因 | 解决方案 |
|
||||
|---|------|------|----------|
|
||||
| 1 | 原生页面 `??` 报 SyntaxError | 微信小程序 JS 引擎不支持 ES2020 | 用 `!= null` 三元表达式替代 |
|
||||
| 2 | veepoo-measure 白屏 `useRef is not defined` | TSX import 未解构 `useRef` | `import React, { useRef } from 'react'` |
|
||||
| 3 | 扫描不到 M2 设备 | 过滤条件只匹配 `M2`,设备可能广播其他名 | 放宽匹配 M2/VPM/VEEPOO |
|
||||
| 4 | 认证超时 — 回调匹配过早 | `errno:0` 在第 1 次回调就匹配 | 只匹配 `connection:true` |
|
||||
| 5 | 认证超时 — 监听器注册过早 | `onLoad` 时适配器未初始化 → `not init` | 改到 `connection:true` 后注册 |
|
||||
| 6 | 认证超时 — 字段检查错误 | 检查 `VPDevicepassword`(值="0000")而非 `VPDeviceAck` | 改为检查 `VPDeviceAck` |
|
||||
| 7 | `deviceChipStatus` 轮询失败 | SDK 写入布尔值 `true` 而非字符串 | 同时匹配字符串和布尔值 |
|
||||
| 8 | `vibrateShort` promise rejection | DevTools 不支持 `type` 参数,try/catch 无法捕获异步 rejection | 改用 `.catch()` |
|
||||
| 9 | dist 不更新 | `dev:weapp` watch 不监听 `native/` 目录 | 修改原生页面后需 `rm -rf dist/pkg-veepoo` 重建 |
|
||||
|
||||
## dev:weapp 注意事项
|
||||
|
||||
- 原生页面修改后**不会自动热更新**,需手动清除 dist 重建
|
||||
- DevTools 日志需要选中正确的页面上下文(`pkg-veepoo`)才能看到原生页面日志
|
||||
- 关闭 DevTools 后重新打开,确保加载最新的 dist 文件
|
||||
192
docs/discussions/2026-06-25-analysis/00-INDEX.md
Normal file
192
docs/discussions/2026-06-25-analysis/00-INDEX.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# HMS 健康管理平台 — 项目负责人决策简报
|
||||
|
||||
> 日期: 2026-06-25 | 分支: feat/media-library-banner | 阶段: V1 CONDITIONAL GO(上线临门一脚)
|
||||
> 范围: 9 维度评分 → TOP 12 痛点收敛为 TOP 5 决策项 → 6 主题战略 → 4 阶段路线图 → TOP 7 行动建议
|
||||
> 面向: V1 上线后 6-12 个月演进,不重复上线前就绪度讨论
|
||||
> 证据口径: 所有论断附 `文件路径:行号` 或 grep 结果,基于 feat/media-library-banner 分支实测
|
||||
|
||||
---
|
||||
|
||||
## 一、执行摘要
|
||||
|
||||
HMS 是一个工程完成度相当高的医疗 SaaS 平台:后端架构分层严格(L1/L2/L3 + 无环依赖图 + Outbox 事件总线 + 双层多租户),PII 加密与 V2 审计 CRITICAL 修复已达企业级,AI 模块 4 Provider + ReAct + RAG 工程完成度高,前端架构成熟、小程序并发治理扎实。**综合评分 6.8 / 10(B)**,处于「可支撑 V1 上线,但距生产级品质与主动关怀引擎定位还差关键闭环」的阶段。
|
||||
|
||||
本次分析发现的核心张力是:**多处宣称已完成的自动化链路实际断裂,且真相源(wiki)与代码系统性漂移,叠加支撑层(DevOps 4.2 / 测试 5.5)严重缺位,使上层可靠性无护栏兜底**。最尖锐的 4 个 CRITICAL 级问题中,3 个是「文档说已修复、代码说没有」——死信重试从未接线(PP-01)、AI 分析队列只入队不消费(PP-05)、RLS 误记 FORCE(PP-07);第 4 个是生产 Redis 密码明文进 git 追踪的 wiki(PP-03)。叠加 2026-09-01 确定性硬截止的 device_readings 分区过期(PP-02),HMS 当前处于「能上线,但上线后 3 个月内有 4 个定时炸弹」的状态。
|
||||
|
||||
**决策层结论:V1 可按计划上线,但上线前必须完成 Phase 0 护航清单(≤2 周),否则上线后 MTTR 不可控、客户基于错误假设运营、AI 主动关怀承诺空转。**
|
||||
|
||||
---
|
||||
|
||||
## 二、综合评分
|
||||
|
||||
### 综合分: **6.8 / 10 (B)**
|
||||
|
||||
加权规则:后端架构 / 安全合规 / 数据层 权重高(×1.3);后端业务 / Web / 小程序 / AI 权重中(×1.0);测试 / DevOps 低分但权重中(×0.9,避免低分区过度拉低但保留短板信号)。
|
||||
|
||||
### 维度评分表
|
||||
|
||||
| # | 维度 | 分数 | 一句话定位 |
|
||||
|---|------|------|-----------|
|
||||
| 1 | 后端架构 | **8.0** | 全系统最扎实:L1/L2/L3 分层严格 + 无环依赖(Kahn 拓扑)+ Outbox 持久化 + 双层多租户;扣分在死信未接线(events.rs:382 零调用)+ module.rs 918 行超限 + 4/8 模块 register_event_handlers 空壳 |
|
||||
| 2 | 后端业务实现 | **7.5** | CAS 并发控制与事务边界扎实(积分/库存/排班/签到全部 update_many+version);扣分在 check-then-insert 竞态 + 状态白名单不一致 + VersionMismatch 无重试 + points/alert/stats 无单测 |
|
||||
| 3 | 数据层 | **7.3** | schema/软删除/UUIDv7/SQL 注入防护优秀,JSONB GIN 在 risk_service 落地;扣分在分区无自愈(PP-02)+ RLS 缺 FORCE(PP-07)+ 连接池 SET 串扰(PP-08)+ pg_trgm 装了没用 + m000109 down 迁移静默失效 |
|
||||
| 4 | 安全合规 | **7.3** | PII 加密(AES-256-GCM + KEK/DEK + HMAC 盲索引)企业级 + V2 CRITICAL 已修;硬伤:Redis 密码泄露(PP-03)+ token 黑名单非分布式 + 患者姓名明文(PP-12)+ 无数据导出 |
|
||||
| 5 | AI 能力 | **6.5** | 4 Provider + ReAct + RAG + 引用溯源工程完成度高(17.7k LOC/196 测试);半成品自动化(AnalysisQueue 死存储 PP-05 + SSE token len/4 计量失真 + 无主动触达) |
|
||||
| 6 | Web 前端 | **7.2** | 架构成熟(routeConfig + DEV 校验 + 6 store + token 预刷新 + any 16→1);僵尸 UI(6 条 navigate 死链 PP-09 + value=0 占位)+ i18n 全缺 + AuthButton 仅 28% |
|
||||
| 7 | 小程序端 | **7.0** | 并发治理/AES 缓存/BLE 离线缓冲生产级;扣分在告警角标写错 Tab(PP-06)+ 11 页缺 elder-mode(与 wiki 不符)+ setTimeout 未治理 + BLE 基础设施 0 单测 |
|
||||
| 8 | DevOps 与可观测性 | **4.2** | 备份加密 + Prometheus 指标 + TLS + 安全头已加固;三支柱全残(无 Alertmanager/Grafana/日志聚合/追踪 PP-04)+ 无 CD + 单副本 + 迁移在启动路径 |
|
||||
| 9 | 测试与质量保障 | **5.5** | 1031 测试函数 + 真实 PG 集成测试是基线;金字塔失衡(无覆盖率工具 + CI 不跑 E2E + CI 集成仅覆盖 erp-server + erp-health 29 service 零内联测试 PP-10) |
|
||||
|
||||
**核心张力:** 架构层(8.0)与支撑层(DevOps 4.2 / 测试 5.5)落差达 3.8 分——「上层的可靠性没有底层的护栏兜底」,这正是潜伏故障(PP-01/PP-05)能流入仓库的根因,也是历史 24% fix 提交率的系统性成因。
|
||||
|
||||
---
|
||||
|
||||
## 三、TOP 5 最紧迫痛点(决策优先级排序)
|
||||
|
||||
> 从 TOP 12 收敛为决策层必须亲自盯的 5 项。每项标注「为什么是决策项而非技术项」。
|
||||
|
||||
### PP-03 [CRITICAL/立即] 生产 Redis 密码明文进 git 追踪的 wiki(含公网 IP),凭据已实际泄露
|
||||
`wiki/infrastructure.md:35/57` 直接写明 `redis://:redis_KBCYJk@129.204.154.246:6379`,git ls-files 确认被追踪。**影响:** Redis 承载限流 token bucket、DEK 缓存、微信 session_key、token 黑名单——接管 Redis = 绕过限流暴破 + 解密微信手机号 + 登出失效。这是唯一一个「已在进行中」的合规事件,每拖一天风险递增,医疗 SaaS 凭据泄露触发等保三级 / 个保法合规事件。**为什么是决策项:** 触碰 CLAUDE.md §3.7「密钥禁止硬编码」铁律,且凭据已暴露给所有仓库访问者(含未来成员/外包),需立即轮换 + git filter-repo 清洗历史,是法律层面的强制义务。
|
||||
|
||||
### PP-02 [CRITICAL/立即] device_readings 分区硬编码到 2026-08,2026-09-01 起 BLE 数据上传全线中断(确定性硬截止)
|
||||
`migration/m20260426_000073_create_device_readings.rs:42-47` 静态建 4 个分区,全仓 grep `PARTITION OF`/`create_partition`/`pg_partman` 无任何未来分区自动创建机制。**影响:** 这是确定性故障而非概率性 bug——今天 2026-06-25 距硬截止仅 ~10 周,发生在 V1 上线后 3 个月、客户已依赖数据时点。Veepoo M2 管线是患者端核心卖点,中断即产品不可用,且影响信任度与续约。**为什么是决策项:** 时间窗固定、影响面 100% 患者端、客户感知度极高,必须在上线前或上线后 2 周内补建未来分区。
|
||||
|
||||
### PP-01 [CRITICAL/立即] 死信重试函数从未接线,业务关键链路「死信即终点」
|
||||
`crates/erp-core/src/events.rs:382` 定义了 `retry_dead_letters`,但 grep 全 crates/ 该符号仅命中定义处,`erp-server/src/tasks.rs` 未注册调度。**影响:** 危急值告警 / 积分发放 / 预约提醒 / article 推送 / follow_up 触发积分——任一因瞬时故障(DB 超时、网络抖动)失败即永久滞留 dead_letter_events 表,wiki 还误记为「每小时定时任务已修复」。**为什么是决策项:** 触碰 CLAUDE.md「每个事件必须有消费者」铁律,文档与代码漂移使团队基于错误假设运营,事故发生时无告警无重试无人工介入流程,MTTR 不可控。
|
||||
|
||||
### PP-04 [CRITICAL/立即] 可观测性三支柱全残缺,生产处于「盲飞」状态
|
||||
`docker/prometheus/alerts.yml` 有 12 条告警规则但 `docker/` 下 grep alertmanager/loki/jaeger 0 命中——规则只写入 TSDB 无任何通知出口;`docker/grafana/provisioning/` 为空目录;日志仅 stdout 无聚合;30+ 文件含 tracing:: 宏但无 trace_id 贯穿。**影响:** 配合 PP-01/PP-02/PP-05 等潜伏故障,数据库宕机、5xx 飙升、Redis 不可达等 critical 告警完全无人知晓,事故发生到客户投诉之间团队毫无察觉,跨模块医疗业务无 trace_id 无法还原请求路径。**为什么是决策项:** 这是 DevOps 4.2 分的根本原因,也是 V1 上线后 6-12 个月最大的运营风险,决定上线后「是睡觉还是救火」。
|
||||
|
||||
### PP-09 [HIGH/立即] Web 工作台 4 个 Dashboard 共 6 条 navigate 指向不存在路由 + value={0} 占位
|
||||
AdminDashboard.tsx:51 `navigate('/health/follow-ups')`、DoctorDashboard 同样指向 `/health/lab-reports`/`/health/vital-signs`、OperatorDashboard:69 等 3 处 `navigate('/health/points')`——对照 App.tsx:296-359 路由表均不存在,PrivateRoute 未匹配前缀默认 403;叠加 AdminDashboard.tsx:88 `value={healthDataStats ? 0 : 0}` 死代码占位。**影响:** 影响 100% 角色、100% 用户、上线即暴露——管理员/医生/运营/护士首屏点击核心数据卡片全部跳 403,「咨询待回复」「线下活动」恒显示 0。医疗管理后台展示假数据是信任度致命伤。**为什么是决策项:** 这是当前未提交改动(统计仪表盘重构)引入的新缺陷,必须在合并前修复,否则首日客户演示即翻车。
|
||||
|
||||
---
|
||||
|
||||
## 四、6 主题一句话战略
|
||||
|
||||
| # | 主题 | 一句话战略 |
|
||||
|---|------|-----------|
|
||||
| T1 | **稳定性与上线护航** | 以「确定性故障自愈」为核心(PP-01 死信接线 + PP-02 分区补建 + PP-05 AI 队列通电),配合迁移解耦启动路径 + cron_heartbeat 进就绪门禁 + Alertmanager 三级告警,把「上线即救火」变成「上线即睡觉」。详见 `01-stability.md` |
|
||||
| T2 | **技术债与架构演进** | 把五类结构性缺陷(事件契约/模块边界/后台任务/多租户隔离/schema 演进)从「文档约定 + 人肉记忆」升级为「编译期/CI 可校验的类型契约 + 机制层防护」(FORCE RLS + SET LOCAL + TaskRegistry + Schema 注册表),为 2-3 年后微服务化打下「可演进而非可重写」的地基。详见 `02-architecture.md` |
|
||||
| T3 | **AI 智能化纵深** | 以「闭合已存在但空转的能力」为第一性原理分三步走——通电(PP-05 AnalysisQueue 消费者)→ 可信(双层记忆 + 引用溯源 + RAG 评估集)→ 可度量(真实 token 成本 + 建议转化率仪表盘),把 HMS 从「医生敲回车才出报告」进化为「感知→推理→行动→复盘」的主动关怀引擎。详见 `03-ai-depth.md` |
|
||||
| T4 | **多端体验统一** | 以「行为契约一致」取代「像素级对齐」作为多端度量:同一份设计 Token 单源 + 同一份交互契约 + 同一类语义组件(AlertCard/EmptyState/VitalCard),先根治 PP-09 死链与 PP-06 角标错位(0.5 人日/项速赢),再以 CI 可校验的硬约束让设计系统从审美问题升级为医疗可见性错误防线。详见 `04-multidevice-ux.md` |
|
||||
| T5 | **医疗合规与数据治理** | 把 HMS 从「修漏洞式合规」升级为「可举证的持续合规闭环」:以 PII 加密对称(患者姓名加密 PP-12)+ 数据分类分级引擎 + 数据主体权利履行通道(个保法 §45/§47 + 病历 15 年留存)+ 审计哈希链接线(verify_hash_chain 零调用修复)为四大支柱,输出机器可验证的合规证据链。详见 `05-compliance.md` |
|
||||
| T6 | **商业增长与 SaaS 规模化** | 缝合现有但分散的半成品(ai_usage 配额引擎 + points 6 表 + EventBus + AnalysisQueue)为三根商业主线——统一计量计费中枢 + 配置化交付蓝图(实施周期 3-5 人天降小时级)+ 主动关怀行为飞轮,在签第 2-5 个客户前确立计量与分层的数据基础,避免陷入「每客户一份定制合同」的项目制泥潭。详见 `06-saas-growth.md` |
|
||||
|
||||
---
|
||||
|
||||
## 五、分阶段路线图
|
||||
|
||||
### Phase 0 — 上线护航(0-2 周,T-0 到 T+2w)
|
||||
**目标:消灭 4 个 CRITICAL 定时炸弹,确保上线即不崩。**
|
||||
|
||||
- [ ] **PP-03 Redis 凭据应急轮换** — 立即改 .env.production 注入新密码 + 重建 Redis 数据 + git filter-repo 清洗历史 + 通知仓库访问者 + 审计 Redis 访问日志(1 人日止血,filter-repo 留待正常窗口)
|
||||
- [ ] **PP-01 死信重试接线** — erp-server/tasks.rs 注册 `start_retry_dead_letters` 每小时调度(复用已实现 events.rs:382)+ 补集成测试 + 修正 wiki 误记(2-3 人日,代码已实现 90% 差最后 10% 接线)
|
||||
- [ ] **PP-02 分区自愈** — 短期:手动写 m000170 用 generate_series 补建 2026_09~2027_06 分区(2 人日,确定性硬截止解除);中期:引入 pg_partman 或 start_partition_maintenance 定时任务
|
||||
- [ ] **PP-09 工作台死链修复** — 修正 4 个 Dashboard 的 navigate 路径(如 `/health/follow-ups` → `/health/follow-up-tasks`)+ 清理 value={0} 占位 + routeConfig 增加 DEV 期 navigate 目标存在性校验(0.5 人日,影响 100% 角色)
|
||||
- [ ] **PP-07 RLS 补 FORCE** — 单个迁移 ALTER TABLE ... FORCE ROW LEVEL SECURITY 遍历所有 tenant_id 表(沿用 m000088 DO 块模板,1 人日,立即堵死 owner-bypass 路径)
|
||||
- [ ] **wiki 真相源全量校正** — 至少修正 PP-01/PP-07(RLS FORCE)/长者模式 83%(实非 100%)/Testcontainers(实本地 PG) 四处误记 + cron_heartbeat 进 /health/ready(速赢,2-3 人日)
|
||||
|
||||
### Phase 1 — 稳固期(1-3 个月,T+1M 到 T+3M)
|
||||
**目标:建立可观测性与测试门禁,让生产「可见」、回归「可防」。**
|
||||
|
||||
- [ ] **PP-04 可观测性三件套** — Alertmanager + 企微/钉钉 webhook 通知路由(先只开 SEV-1 避免狼来了)+ Grafana provisioning(DB/Redis/HTTP/EventBus 4 个 dashboard)+ Loki 日志聚合 + trace_id 贯穿(4-6 人日)
|
||||
- [ ] **PP-10 覆盖率门禁** — tarpaulin 接入 CI + service 层 ≥60% 门禁 + erp-health 29 service 补内联单测 + E2E 进 CI + erp-health/tests/ 进 CI(4-6 周)
|
||||
- [ ] **PP-06 告警角标修复** — useAlertPolling 改按 pagePath 动态查找 Tab 索引 + 补 elder-mode 11 页缺失 + setTimeout 统一走 useSafeTimeout + BLE 基础设施补单测(5-7 人日)
|
||||
- [ ] **PP-08 多租户连接池串扰修复** — 会话变量改 SET LOCAL 事务作用域 或 per-request 连接 + 跨租户并发集成测试(2.5 周,机制层消灭跨租户泄漏窗口)
|
||||
- [ ] **PP-05 AI 队列消费者 MVP** — claim_next 参数化 + SKIP LOCKED 加固 + 至少实现健康告警→AI 评估→患者推送 1 条完整链路(先 truncate 历史积压避免 Ollama OOM)
|
||||
- [ ] **PP-11 迁移解耦启动路径** — 独立 migrate 子命令(从 main.rs:233 剥离)+ 30 分钟回滚能力 + 破坏性 DDL expand/contract 三步走(8-12 人日)
|
||||
|
||||
### Phase 2 — 深化期(3-6 个月,T+3M 到 T+6M)
|
||||
**目标:从「被动工具」跨越到「主动关怀引擎」,补齐合规通道。**
|
||||
|
||||
- [ ] **AI 主动关怀闭环** — 5 条 AnalysisQueue 链路全部接消费者(告警/化验/透析/巡护/高风险)+ 每日扫描主动触达患者 + Orchestrator 上下文压缩 + SSE token 精确计量 + 双层长期记忆(11 周,AI 主题举措 1-2)
|
||||
- [ ] **PP-12 合规通道** — 患者数据导出 API(个保法 §45)+ 删除/留存策略引擎(病历 15 年 vs 个保法 §47 法律冲突的 anonymize 中间态)+ 患者姓名加密 + name_hash 盲索引 + DEK 轮换闭环(12-15 人日)
|
||||
- [ ] **PP-11 CD pipeline** — GitHub Actions build/push/deploy workflow + 蓝绿/灰度发布 + 双副本(+30% 资源成本)+ PG advisory lock 选主(防后台任务多副本重复执行)
|
||||
- [ ] **性能索引治理** — pg_trgm 索引在患者/医生姓名模糊搜索落地 + JSONB GIN 覆盖率从 5% 提到关键查询 80% + 慢查询监控 + 物化视图(Dashboard DB CPU 降 50%+)
|
||||
- [ ] **多端体验统一** — 设计 Token 单源(DTCG JSON 代码生成)+ 跨端语义组件契约(AlertCard/EmptyState/VitalCard)+ Web i18n 框架(187 处硬编码文案)+ AuthButton 28%→70% + Web Vitals 监控
|
||||
|
||||
### Phase 3 — 规模化(6-12 个月,T+6M 到 T+12M)
|
||||
**目标:多副本 HA + 分布式安全 + SaaS 计量化 + 医疗合规认证就绪。**
|
||||
|
||||
- [ ] **高可用与分布式安全** — 应用多副本 + PG 主从 + Redis 哨兵/集群 + 满足 99.9% SLA + token 黑名单迁 Redis + DB/Redis 连接 TLS + 会话密钥分布式存储 + 密钥轮换自动化
|
||||
- [ ] **统一计量计费中枢(SaaS 主题)** — Metering Hub(每 AI 分析/告警/咨询/上传可计量可计费)+ 配置化交付蓝图(Tenant Blueprint + Onboarding,实施周期降小时级)+ 套餐分层 Feature Gating
|
||||
- [ ] **医疗合规认证** — ICD 编码校验 + 药品编码 + 等保三级测评准备 + 个保法合规审计 + 病历留存策略引擎 + 数据分类分级引擎(字段级 ABAC)+ 审计哈希链完整性举证
|
||||
- [ ] **AI 能力深化** — ReAct Agent 多轮工具调用稳定化 + Function Calling 双层记忆 + 知识库 V2 + 多模态(化验单图像识别)+ RAG 评估闭环 + Provider FC 集成测试 + 成本真相化
|
||||
- [ ] **生态扩展预留** — 白标皮肤(branding_json + 主题注入)+ 开放 API(API Key 吊销/轮换/审计)+ ISV 生态接入基础
|
||||
|
||||
---
|
||||
|
||||
## 六、TOP 7 行动建议(给项目负责人)
|
||||
|
||||
> 可执行、有优先级、有截止时点、有负责人。
|
||||
|
||||
1. **【今天】启动 Redis 凭据泄露应急响应(PP-03)** — 立即轮换密码 + 改 .env.production 注入 + 重建 Redis 数据 + git filter-repo 清洗历史 + 通知所有仓库访问者 + 审计 Redis 访问日志。这是唯一一个「已在进行中」的合规事件,每拖一天风险递增,属法律层面强制义务。**负责人:安全/DevOps。**
|
||||
|
||||
2. **【本周】修复 3 个 CRITICAL 定时炸弹(PP-01/PP-02/PP-07)** — 死信重试接线 + 分区补建迁移 + RLS 补 FORCE。这三项均「代码已实现 90%,差最后 10% 接线」,投入小、收益大,且能立即修正 wiki 真相源漂移。**负责人:后端架构。**
|
||||
|
||||
3. **【本周】冻结统计仪表盘重构分支合并,直到 PP-09 修复** — 当前未提交改动(AdminDashboard/DoctorDashboard/OperatorDashboard/NurseDashboard x4)引入了 6 条死链 navigate + value={0} 占位,合并前必须通过「navigate 目标存在性」DEV 校验。影响 100% 角色首屏,上线即暴露。**负责人:前端。**
|
||||
|
||||
4. **【本月】做一次 wiki 真相源全量校正 + cron_heartbeat 进就绪门禁** — 至少修正 4 处误记(retry_dead_letters/RLS FORCE/长者模式 100%/Testcontainers),并在 CI 增加 doc-vs-code 一致性校验;cron_heartbeat 接入 /health/ready(>2×周期返回 503 触发 nginx 摘流)是所有后续观测/告警的门禁基线,零依赖 1-2 人日。**负责人:架构。**
|
||||
|
||||
5. **【Phase 1】把可观测性三件套(PP-04)列为上线后第一优先** — Alertmanager + Grafana + Loki,这是 DevOps 4.2 分的根本短板,也是 PP-01/PP-02/PP-05 这类潜伏故障能在生产盲飞数月的直接原因。先只开 SEV-1 避免告警风暴。**负责人:DevOps。**
|
||||
|
||||
6. **【Phase 1】建立测试覆盖率门禁(PP-10)** — tarpaulin + service 层 ≥60% 门禁 + E2E 进 CI + erp-health/tests/ 进 CI。历史 24% fix 提交率的根因是回归防护薄弱,没有门禁则 6-12 个月演进速度被测试债务持续拖累,AI Agent/Provider/SSE 链路演进尤其危险。**负责人:测试。**
|
||||
|
||||
7. **【Phase 0-1】把 PP-05 AI 队列消费者作为「主动关怀引擎」承诺的兑现起点** — claim_next 参数化(消除 format! 拼 tenant_id 的 SQL 注入铁律违反)+ 至少打通 1 条「健康告警→AI 评估→患者推送」完整链路。客户基于「主动关怀」承诺付费,目前 AnalysisQueue 是死存储,这是续约与口碑的核心,也是从「分析工具」到「主动关怀」产品跨越的根因。**负责人:AI。**
|
||||
|
||||
---
|
||||
|
||||
## 七、主题章节索引
|
||||
|
||||
本简报为决策层入口,各主题详细分析(愿景/举措/速赢/风险/工作量估算)见对应章节文件:
|
||||
|
||||
| 主题 | 文件 | 核心举措数 |
|
||||
|------|------|-----------|
|
||||
| T1 稳定性与上线护航 | `01-stability.md` | 5 举措 + 3 速赢 + 7 风险 |
|
||||
| T2 技术债与架构演进 | `02-architecture.md` | 5 举措 + 4 速赢 + 6 风险 |
|
||||
| T3 AI 智能化纵深 | `03-ai-depth.md` | 5 举措 + 4 速赢 + 8 风险 |
|
||||
| T4 多端体验统一 | `04-multidevice-ux.md` | 5 举措 + 4 速赢 + 9 风险 |
|
||||
| T5 医疗合规与数据治理 | `05-compliance.md` | 4 举措 + 3 速赢 + 8 风险 |
|
||||
| T6 商业增长与 SaaS 规模化 | `06-saas-growth.md` | 5 举措 + 3 速赢 + 8 风险 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:决策张力图谱
|
||||
|
||||
```
|
||||
架构层(8.0)─────────────────────── 可靠性上层
|
||||
│
|
||||
├── 后端架构 8.0 ← 最扎实(无环依赖 + Outbox + 双层多租户)
|
||||
├── 后端业务 7.5 (CAS 并发控制扎实,但 check-then-insert 竞态)
|
||||
├── 数据层 7.3 (schema 优秀,但分区无自愈 + RLS 缺 FORCE)
|
||||
└── 安全合规 7.3 (PII 加密企业级,但 Redis 凭据已泄露)
|
||||
│
|
||||
↓ 落差 3.8 分(上层可靠性无底层护栏兜底)
|
||||
│
|
||||
支撑层(4.2-5.5)─────────────────── 护栏底层
|
||||
├── 测试 5.5 ← 回归防护薄弱(PP-01/PP-05 流入根因,24% fix 提交率)
|
||||
└── DevOps 4.2 ← 可观测性盲飞(PP-04 是最大运营风险)
|
||||
|
||||
核心张力:上层的可靠性没有底层的护栏兜底。
|
||||
决策方向:Phase 0 拆炸弹 + Phase 1 补护栏 = 让可靠性「可见、可防、可回滚」。
|
||||
```
|
||||
|
||||
## 附录 B:跨维度主题(系统性模式)
|
||||
|
||||
本次分析识别出 10 个跨维度系统性主题,是「症状」背后的「模式」,需作为演进期间的持续治理对象:
|
||||
|
||||
1. **文档与代码漂移** — wiki 多处「已修复」与代码不符(PP-01/PP-07/长者模式/Testcontainers),真相源失真是 V1 上线后最隐蔽的系统性风险。
|
||||
2. **「半成品自动化」模式** — retry_dead_letters 未接线(PP-01)+ AnalysisQueue 死存储(PP-05)+ ai.dialysis.kdigo_requested 空 no-op + 32 个 FIRE-AND-FORGET 事件无消费者,系统停留在「被动工具」而非「主动关怀引擎」。
|
||||
3. **测试金字塔失衡 + 覆盖率工具缺失(PP-10)** — 历史根因,是所有潜伏故障流入仓库的共同成因。
|
||||
4. **「有代码无数据」僵尸 UI(PP-09)** — value={0} 占位 + 死链 navigate,医疗后台展示假数据是信任度致命伤。
|
||||
5. **多租户隔离多层防御可信度不足(PP-07/PP-08)** — RLS 缺 FORCE + 连接池 SET 串扰,医疗数据跨租户泄漏是合规红线。
|
||||
6. **凭据与密钥管理散落** — Redis 密码泄露(PP-03)+ Redis/PG 无 TLS + token 黑名单非分布式 + HS256 对称密钥,阻塞水平扩展。
|
||||
7. **可观测性近乎为零(PP-04)** — DevOps 4.2 分核心短板,决定上线后「是睡觉还是救火」。
|
||||
8. **生产发布与回滚能力缺失(PP-11)** — 无 CD + 无灰度 + 单副本 + 迁移在启动路径,6-12 个月演进速度核心瓶颈。
|
||||
9. **性能索引黑洞** — pg_trgm 装了没用 + JSONB GIN 覆盖率 ~5%,数据膨胀后成性能黑洞。
|
||||
10. **硬编码与配置散落** — TabBar 索引(PP-06)+ 分区日期(PP-02)+ value={0}(PP-09),配置未集中治理。
|
||||
|
||||
---
|
||||
|
||||
> 本简报基于 9 维度并行分析汇编,所有论断均附证据(文件路径:行号或 grep 结果),已在各主题章节展开。决策层如需深挖单项,直接跳转对应主题章节文件。
|
||||
125
docs/discussions/2026-06-25-analysis/01-stability.md
Normal file
125
docs/discussions/2026-06-25-analysis/01-stability.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 稳定性与上线护航 — 主题综合
|
||||
|
||||
> 日期: 2026-06-25 | 主题: 稳定性与上线护航(主持综合)
|
||||
> 视野: V1 上线后 6-12 个月演进,不重复上线前就绪度讨论。
|
||||
> 证据口径: 所有论断附 `文件:行号`,基于 feat/media-library-banner 分支实测。
|
||||
|
||||
## 1. 主题愿景
|
||||
|
||||
把"上线即救火"变成"上线即睡觉"。融合 SRE / 发布管理 / 质量保障三方共识:以**确定性故障自愈**为核心(死信重试、分区到期、AI 队列积压——这三类都是"代码写了但没接线"或"硬截止定时炸弹",是历史 24% fix 提交率在事故层面的根因),以**迁移可逆性 + 快速回滚**为发布纪律,以**告警触达人 + cron_heartbeat 进就绪门禁**为观测闭环。三者形成"预防—发布—响应"完整生命周期,而非堆砌监控工具。
|
||||
|
||||
不追求一步到位上 K8s+Helm+完整可观测性栈(DevOps 4.2 分、单兵运维团队不支撑这种复杂度债务)。最小可行 HA = migrate 子命令 + 蓝绿 + heartbeat 门禁 + Alertmanager,每一步都是可验证的工程动作,配合 TDD 集成测试证明"接线真的生效"。
|
||||
|
||||
## 2. 专家提案摘要
|
||||
|
||||
### SRE 可靠性工程师(5 项)
|
||||
- **PP-04 告警触达人**:补 Alertmanager + 三级 SEV 分级 + Runbook deep-link + webhook 限流治理。
|
||||
- **PP-01/PP-05 死信与 AI 队列接线**:`retry_dead_letters`(events.rs:382) 和 `analysis_queue::claim_next`(service/analysis_queue.rs:92) 已实现但脱节,接线 + cron_heartbeat 复用为后台存活探针。
|
||||
- **PP-02 分区自愈**:应急 guard(每日检测未来分区数<2 则补建)+ pg_partman 根治,剩余周数作 SEV-1 倒计时告警。
|
||||
- **灾备演练制度化**:restore-drill CI job → RPO/RTO Prometheus 指标。
|
||||
- **PP-11 迁移解耦启动路径**:migrate 子命令 + 蓝绿 + 破坏性 DDL 三步走。
|
||||
|
||||
### 发布管理专家(5 项)
|
||||
- **迁移可逆性工程化**:clap 子命令暴露 173 条已存在但休眠的 down 迁移为可调用能力;expand/contract 强制破坏性变更跨版本。
|
||||
- **30 分钟回滚**:`migrate down <ver>` + 双标签镜像 + canary 用 `eventbus_pending_total`(tasks.rs:119) 作健康判据。
|
||||
- **cron 进就绪门禁**:`/health/ready`(health.rs:51) 暴露 cron_heartbeat + 积压阈值,503 触发摘流。
|
||||
- **系统级特性开关**:FeatureFlagService(ai 内部)上提为 erp-core trait,按 tenant 粒度灰度。
|
||||
- **冻结期 + fire-drill**:双周演练 + 兼容窗口契约沉淀到 docs/runbooks/。
|
||||
|
||||
### 质量保障架构师(占位,观点合并入下)
|
||||
门禁反推视角已贯穿上述——TDD 集成测试证明接线生效、CI 门禁拦截破坏性 DDL、fire-drill 验证回滚链路,本主题不再单列。
|
||||
|
||||
## 3. 战略举措(归并后 5 项)
|
||||
|
||||
### 举措 A:确定性故障自愈接线(PP-01 + PP-05 + PP-02 应急)
|
||||
- **rationale**:retry_dead_letters 已实现 (events.rs:382-446,含 max_attempts=5 filter events.rs:390) 但全仓无调用者;ai_analysis_queue 表已建 (m000118) 且 claim_next 已实现 (analysis_queue.rs:92) 但只在 module 启动触发一次;device_readings 是分区表,down() 只 DROP 固定 4 个月份 (2026_05..2026_08),2026-09 后 INSERT 将硬失败。三处都是"代码写了但没接线"的定时炸弹。
|
||||
- **phases**:
|
||||
1. tasks.rs 新增 `start_dead_letter_retry`(每小时调 retry_dead_letters,复用 cron_heartbeat)+ `start_ai_queue_worker`(消费 ai_analysis_queue)。
|
||||
2. tasks.rs 新增 `start_device_readings_partition_guard`(每日检测 `pg_inherits` 未来分区数<2 则 CREATE PARTITION OF)。
|
||||
3. TDD:先写 `tests/event_retry_loop.rs`、`tests/partition_guard.rs` 失败测试,再接线。
|
||||
- **effortEstimate**:3-5 人日(函数均已存在,主要是接线 + 测试)。
|
||||
- **expectedImpact**:消除 3 类潜伏故障;死信不再永久驻留;2026-09 分区硬截止提前自愈。
|
||||
- **kpis**:dead_letter_events.resolved_at 非空率 >95%;ai_analysis_queue pending 24h 内清零;device_readings 未来分区数 ≥3。
|
||||
- **dependencies**:cron_heartbeat metric 暴露(举措 C);Alertmanager 告警规则(举措 D)。
|
||||
|
||||
### 举措 B:迁移解耦启动路径 + 30 分钟回滚能力
|
||||
- **rationale**:main.rs:233 在应用启动路径直接 `Migrator::up`,含 RENAME/DROP 等破坏性 DDL;173 条 down 迁移已写好但全仓无 clap/migrate 子命令可调用(Cargo.toml 无 clap 依赖);docker-compose.production.yml:38 单容器 `hms-server`,nginx.conf:1 单 upstream;.github/workflows 仅 test.yml 无构建推送。回滚资产存在但休眠。
|
||||
- **phases**:
|
||||
1. 引入 clap 子命令:`erp-server serve`(仅启动 HTTP,不迁移)/ `migrate up|down <ver> --dry-run --confirm` / `migrate verify`(事务回滚校验 down 可逆性)。
|
||||
2. main.rs:233 改为启动时仅校验 schema 版本一致性,不匹配则 panic 提示先跑 migrate。
|
||||
3. CI 新增 build-and-push workflow:双标签 `{git-sha}` + `{semver}` 推 ghcr.io。
|
||||
4. deploy.sh:canary 10% 流量,观察 `/metrics` 5xx + eventbus_pending_total 5 分钟,异常切回 stable + migrate down。
|
||||
5. 破坏性 DDL 写入 docs/runbooks/breaking-ddl.md 强制 expand/contract 三步走 checklist。
|
||||
- **effortEstimate**:8-12 人日(clap 改造 + CI + deploy 脚本 + 破坏性迁移 checklist)。
|
||||
- **expectedImpact**:回滚从"重拉镜像"升级为 30 分钟内可执行;破坏性 DDL 不再在启动瞬间执行。
|
||||
- **kpis**:MTTR <30min;破坏性迁移 100% 走 expand/contract;任意历史镜像可拉取。
|
||||
- **dependencies**:backup.sh 作为回滚前快照(已存在);nginx upstream 改造(举措 B 第 4 步)。
|
||||
|
||||
### 举措 C:cron_heartbeat 进就绪门禁 + 积压指标可观测
|
||||
- **rationale**:cron_heartbeat 已埋点 (main.rs:647 → state.rs:31 → tasks.rs 已写),但 readiness_check (health.rs:51) 仅查 DB+Redis,后台任务死了就绪仍返回 ok;tasks.rs:119 已暴露 eventbus_pending_total,但 dead_letter 积压、AI 队列积压、分区剩余周数未导出。零成本资产未利用。
|
||||
- **phases**:
|
||||
1. ReadyResponse (health.rs:33) 增 `crons: [{name, last_heartbeat_ago_secs, healthy}]`,>2×周期判 unhealthy 返回 503。
|
||||
2. tasks.rs 新增 gauge:`dead_letter_unresolved_total`、`ai_analysis_queue_pending_total`、`device_readings_partitions_remaining_weeks`。
|
||||
3. 公开路由屏蔽 crons 字段防信息泄漏。
|
||||
- **effortEstimate**:2-3 人日。
|
||||
- **expectedImpact**:后台任务死亡可被 nginx/k8s 摘流;canary 阶段可直接判断新版本是否杀掉某个 cron。
|
||||
- **kpis**:/health/ready 反映 cron 存活;canary 拒绝率(后台被杀)可观测。
|
||||
- **dependencies**:举措 A 接线后 cron 才有真实心跳。
|
||||
|
||||
### 举措 D:Alertmanager + 三级告警 + Runbook 绑定
|
||||
- **rationale**:prometheus.yml:5 仅有 rule_files 无 alerting 块;alerts.yml 22 条规则但无 SEV 分级、无 Alertmanager、无 Runbook 链接。告警亮了无人知、知了不知干啥。
|
||||
- **phases**:
|
||||
1. docker-compose.production.yml 新增 alertmanager 服务 + prometheus.yml 补 `alerting: alertmanagers:` 段。
|
||||
2. alerts.yml 按 SEV-1/2/3 分级(SEV-1:5xx 率/PG 耗尽/Redis 不可达/dead_letter 积压/分区<2 周 → 企微+电话;SEV-2:P95/idle<10% → 企微;SEV-3:CPU/内存 → 仅 Grafana)。
|
||||
3. 每条 alertname 对应 docs/runbooks/ 一页 + Grafana deep-link。
|
||||
4. 上线初期只开 SEV-1,按周复盘降噪后再开 SEV-2/3(信噪比治理)。
|
||||
- **effortEstimate**:4-6 人日。
|
||||
- **expectedImpact**:值班人被电话叫醒 ≤1 次/天;确定性故障(分区到期、死信积压)提前 2 周报警而非等客户投诉。
|
||||
- **kpis**:告警信噪比(有效告警/总告警)>70%;SEV-1 平均响应 <15min。
|
||||
- **dependencies**:举措 A/C 的 metric 导出;企微 webhook 限流(20/min)评估。
|
||||
|
||||
### 举措 E:灾备演练制度化 + 兼容窗口契约
|
||||
- **rationale**:backup.sh/restore.sh 已实现(AES-256-CBC)但从未验证可恢复;无 fire-drill;无兼容窗口契约文档。医疗 SaaS 业内普遍"有 backup 没 drill"。
|
||||
- **phases**:
|
||||
1. docker/drill/restore-drill.yml:独立 PG 副本,CI 周日 04:00 拉最近 backup → restore → schema diff + 10 条 smoke 查询 → 输出 RPO/RTO metric。
|
||||
2. docs/runbooks/disaster-recovery.md + release.md + rollback.md + partition-deadline.md。
|
||||
3. wiki/architecture.md 新增"发布兼容性"章节 + PR 标注 `compatible_rollback_to`。
|
||||
4. 双周 staging fire-drill + 变更冻结期(月初高峰前 48h)。
|
||||
- **effortEstimate**:6-8 人日(含 CI 隔离 + PII 合规审查)。
|
||||
- **expectedImpact**:RPO/RTO 从纸面 SLA 变成每周可查 metric;客户合规审计直接拿数据。
|
||||
- **kpis**:backup_rpo_seconds / backup_rto_seconds 周报;fire-drill MTTR 记录。
|
||||
- **dependencies**:CI runner 隔离(生产备份含 PII);BACKUP_PASSPHRASE 轮换流程。
|
||||
|
||||
## 4. 速赢(1-2 周内)
|
||||
|
||||
1. **cron_heartbeat 进 /health/ready(举措 C 第 1 步)**:health.rs:33/51 改造,零依赖、1-2 人日,立即可让 nginx 摘流反映后台存活。这是所有后续观测的门禁基线。
|
||||
2. **死信与 AI 队列接线(举措 A 第 1 步)**:tasks.rs 加两个 spawn + 复用 retry_dead_letters/claim_next,3-4 人日,消除"代码写了没跑"的潜伏故障,并 TDD 证明生效。
|
||||
3. **Redis 凭据止血轮换(PP-03 应急)**:立即改 .env.production 注入新密码 + 重建 Redis 数据,不动 git 历史(filter-repo 留待下一正常发布窗口)。1 人日,止血无破坏性。
|
||||
|
||||
## 5. 主题级风险
|
||||
|
||||
- **R1 破坏性 DDL 三步走拉长交付周期 2-3 倍**:产品/研发强烈反对,需明确医疗场景牺牲速度换可靠性的边界,并以"特性开关(举措未单列)作廉价回滚"对冲。
|
||||
- **R2 重试风暴**:retry_dead_letters 广播后消费者仍失败会再次 dead-letter,已确认 max_attempts=5 被 filter(events.rs:390)兜底;但 AI queue worker 启动后历史积压一次性触发大量 LLM 调用 → Ollama OOM,需先 truncate 或加 backpressure。
|
||||
- **R3 蓝绿双副本 +30% 资源成本**:需与运维预算对齐;nginx upstream 切换需 DB schema 兼容期,双写易引入数据不一致。
|
||||
- **R4 BACKUP_PASSPHRASE 轮换需重加密历史备份**:工作量被低估;restore-drill 跑生产备份含 PII,CI runner 必须隔离 + 跑完即销毁。
|
||||
- **R5 告警信噪比未治理重蹈"狼来了"**:上线初期只开 SEV-1;企微 webhook 限流 20/min 需评估,否则告警风暴被腾讯截断。
|
||||
- **R6 /health/ready 返回 503 过敏感致发布期误摘流**:发布期临时调大阈值或加 maintenance 模式。
|
||||
- **R7 pg_partman 需 superuser + shared_preload_libraries**:云 PG(腾讯云)可能限制;私有化部署客户需同步安装扩展。
|
||||
|
||||
## 6. 专家分歧调和(dissentingViews → 最终取舍)
|
||||
|
||||
- **D1 PP-03 Redis 凭据处置优先级**(安全 vs SRE vs 发布三方分歧):SRE 主张业务链路自愈优先;安全主张立即 filter-repo 清洗历史;发布主张 filter-repo 是破坏性"发布"本身会重写哈希破坏分支。**取舍**:采纳发布管理专家的两阶段方案——立即轮换密码止血(无 git 影响)+ 下一正常发布窗口做 filter-repo(带备份+全员协调)。理由:上线临门一脚搞历史重写风险高于泄露被利用的渐进风险,止血优先。
|
||||
- **D2 是否上 K8s+Helm**:SRE 明确反对,主张蓝绿+migrate 子命令是最小可行 HA。**取舍**:采纳 SRE 立场,不上 K8s。理由:DevOps 4.2 分、单兵运维团队把复杂度债务换成事故概率不划算;蓝绿+heartbeat 门禁覆盖 80% 发布安全需求。
|
||||
- **D3 可观测性栈建设时机**(DevOps 专家 vs 发布管理边界之争):发布管理主张先 heartbeat+积压指标覆盖发布决策(1 周可落地),DevOps 主张上完整 Alertmanager/Loki/Jaeger(4-8 周)。**取舍**:两者互补但分期——先落地举措 C(heartbeat 门禁)和举措 D 第 1-2 步(Alertmanager + SEV-1),完整链路追踪(Loki/Jaeger)列入 V1.1 路线图而非 V1 上线阻塞项。
|
||||
- **D4 分区告警方式**(SRE vs 监控专家):SRE 主张按时间倒计时 metric 告警,监控专家常规按错误率。**取舍**:采纳 SRE——确定性故障(硬截止)不该按概率监控,按剩余周数单调递减 gauge 提前 10 周预警。
|
||||
- **D5 特性开关是否上提 erp-core**:违反 CLAUDE.md §1.3 模块边界铁律。**取舍**:以 trait 形式定义在 erp-core,各模块可选依赖(非直接耦合);开关生命周期规范——最多存活 2 个发布周期后强制移除,防 if-flag 蔓延。本主题未单列举措,并入举措 B 回滚工具箱。
|
||||
|
||||
## 7. 路线(6-12 个月)
|
||||
|
||||
- **M0(上线前/上线时,1-2 周)**:速赢 1+2+3 —— heartbeat 门禁、死信/AI 队列接线、Redis 凭据止血。
|
||||
- **M1(上线后 1 个月)**:举措 B 第 1-3 步(clap 子命令 + CI 镜像推送)+ 举措 D 第 1-2 步(Alertmanager + SEV-1)+ 举措 A 第 2 步(分区 guard 应急)。
|
||||
- **M2(上线后 2-3 个月)**:举措 B 第 4-5 步(canary + 破坏性 DDL checklist)+ 举措 E 第 1-2 步(restore-drill + runbooks)+ pg_partman 根治。
|
||||
- **M3(上线后 4-6 个月)**:举措 E 第 3-4 步(兼容窗口契约 + fire-drill 制度化)+ 完整可观测性栈(Loki/Jaeger)评估。
|
||||
- **M4(6-12 个月)**:根据 M1-M3 的 MTTR/RPO/RTO 实测数据,评估是否升级到 K8s+Helm(届时团队规模与 DevOps 成熟度可能已支撑)。
|
||||
|
||||
> 所有举措均强调 TDD 证明接线生效、CI 门禁拦截、fire-drill 验证——质量保障"门禁反推"视角贯穿全程,不单列质量举措。
|
||||
153
docs/discussions/2026-06-25-analysis/02-architecture.md
Normal file
153
docs/discussions/2026-06-25-analysis/02-architecture.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 技术债与架构演进 — 主题综合
|
||||
|
||||
> 日期: 2026-06-25 | 分支: feat/media-library-banner | 阶段: V1 CONDITIONAL GO(上线临门一脚)
|
||||
> 视角: 不看上线前 P0/P1(由安全/DevOps 主题覆盖),专攻"架构债"——即代码当前能跑,但 6-12 个月后会成为微服务化/SaaS 化拦路虎的结构性缺陷。
|
||||
> 前序: 决策简报 `00-INDEX.md`(综合 6.9/10,TOP 5 痛点含 PP-01/PP-02/PP-07);本章节聚焦"结构性演进"而非"上线就绪度"。
|
||||
> 证据口径: 所有论断附文件路径:行号或 grep 结果,已逐项核验。
|
||||
|
||||
---
|
||||
|
||||
## 一、主题愿景(专家共识收敛)
|
||||
|
||||
HMS 的架构骨架(L1/L2/L3 分层 + Outbox 事件总线 + 双层多租户 + ErpModule trait)是全系统最扎实的资产(决策简报后端架构 8.0 分),**但当前"模块化单体"已部分名义化**:模块边界在组装层被绕过、事件契约仅靠文档约定、后台任务无声明式注册入口、多租户隔离依赖"竞态防护"的反模式、数据库 schema 演进无兼容窗口纪律。这些债现在不疼,但 6-12 个月后微服务化/SaaS 化启动时,**会从"重构"退化为"重写"**。
|
||||
|
||||
**主题愿景:** 在保持业务迭代速度的同时,把五类结构性缺陷(事件契约 / 模块边界 / 后台任务 / 多租户隔离 / schema 演进)从"文档约定 + 人肉记忆"升级为"编译期/CI 可校验的类型契约 + 机制层防护",为 2-3 年后的微服务化打下"可演进而非可重写"的架构地基。核心判断标准:**这笔债如果现在不还,拆服务当天是要重写而不是重构**。
|
||||
|
||||
---
|
||||
|
||||
## 二、专家提案摘要与交叉验证
|
||||
|
||||
三位专家(首席架构师 / 后端架构师 / 数据架构师)独立提案,**核心诊断高度一致**,差异仅在实施手法与优先级排序。已核验的关键证据:
|
||||
|
||||
| 诊断项 | 专家共识 | 代码证据(已核验) | alreadyKnown |
|
||||
|--------|---------|-------------------|--------------|
|
||||
| 死信重试未接线(PP-01) | 三人一致认定是"缺少任务注册框架"症状,非单点 bug | `erp-core/events.rs:382` 定义 `retry_dead_letters`,`main.rs:425-673` + `tasks.rs` 全部 spawn 点无调用 | V2 审计已识别为痛点,但"根因是缺 framework"是新视角 |
|
||||
| AI 队列死存储(PP-05) | claim_next 消费循环未接线,与 PP-01 同源 | 与 PP-01 共享"无 TaskRegistry"根因 | 已识别,归入半成品自动化主题 |
|
||||
| RLS 无 FORCE + SET 串扰(PP-07/PP-08) | 三人一致:是同一缺陷两面,须合并修 | grep `FORCE ROW LEVEL SECURITY` 全仓 **0 命中**;`tenant_rls.rs:31` 共享池 `SET app.current_tenant_id`,:44 `RESET` | 已识别,但"SET LOCAL + 事务作用域"是机制层新解法 |
|
||||
| 分区硬编码(PP-02) | 确定性硬截止,优先级高于概率性 bug | `m000073:43-46` 硬编码 2026_05~2026_08,启动路径外无自动建分区 | 已识别,pg_partman + 应用层兜底是增量 |
|
||||
| 事件 schema 无版本治理 | EVENT_SCHEMA_VERSION="v1" 仅写入 payload,无消费者校验 | `events.rs:67` `pub const EVENT_SCHEMA_VERSION: &str = "v1"` | **新发现**(历史只规定命名规范,未约束 schema 演进) |
|
||||
| ErpModule trait 名实不符 | register_event_handlers 8 模块中 4 空壳 | `module.rs:69` default `{}`;auth/config/core/message 全空或 stub;health:408 空函数体 | **新发现**(认知债,非功能 bug) |
|
||||
| 组装层边界泄漏 | dialysis 业务编排下沉到 erp-server | `erp-server/src/dialysis_workflow.rs` 直接订阅 `dialysis.record.created` 并编排 BPMN,违反 §1.3 L2 零直接依赖 | **新发现**(架构铁律被默默绕过) |
|
||||
|
||||
---
|
||||
|
||||
## 三、战略举措(归并为 5 项)
|
||||
|
||||
### 举措 A:后台任务声明式注册(TaskRegistry + spawn_workers)— 偿还"未接线死代码"债
|
||||
|
||||
**Rationale:** PP-01(retry_dead_letters)和 PP-05(claim_next)反复出现的根因不是"忘了接线",而是 ErpModule 没有"后台工作器"这一等公民概念,每个新任务都靠开发者记得在 `main.rs` 手动 spawn。一次性建立 framework 后,未来新增定时任务(分区维护、归档、留存策略)都有标准路径。
|
||||
|
||||
**Phases:**
|
||||
1. **Phase A1(接线,1 周):** `erp-core/src/module.rs` 的 ErpModule trait 新增 `async fn spawn_workers(&self, ctx) -> AppResult<Vec<JoinHandle<()>>>`(default 空 Vec);把 `main.rs:425-673` 散落的 `start_event_cleanup / start_pool_metrics / start_auto_analysis / start_dialysis_workflow_orchestrator / start_outbox_relay / start_timeout_checker` 全部下沉到各模块 `spawn_workers`;erp-health 在 spawn_workers 中以 `tokio::time::interval(3600s)` 驱动 `retry_dead_letters`,erp-ai 驱动 `claim_next` 消费循环(每 30s 批量 20 条,`SELECT FOR UPDATE SKIP LOCKED` 防多副本)。
|
||||
2. **Phase A2(防回归,0.5 周):** 编译期静态断言 + 集成测试:grep `pub async fn retry_dead_letters` 和 `pub async fn claim_next` 必须在 spawn_workers 实现中被引用;扩展 `CronHeartbeat` 为 `CancellationToken` 统一 graceful shutdown。
|
||||
3. **Phase A3(可观测,0.5 周):** `/health/tasks` 端点暴露每个任务 `last_heartbeat / max_expected_interval`,对接 Alertmanager "任务卡死"告警。
|
||||
|
||||
- **effortEstimate:** 2 周(含测试 + graceful shutdown 改造)
|
||||
- **expectedImpact:** 兑现"主动关怀引擎"承诺(死信不再永久滞留)+ 消除 2 处死代码认知污染 + 为未来 HA 多副本选主铺路
|
||||
- **kpis:** retry_dead_letters 每小时执行且 heartbeat 可观测;claim_next 队列积压 < 100;死信表 resolved_at 非空率 > 95%
|
||||
- **dependencies:** PP-04 可观测性三件套(Alertmanager)配合;多副本部署时需 PG advisory lock 选主(Phase C 蓝绿引入时)
|
||||
|
||||
### 举措 B:多租户隔离机制层根治(FORCE RLS + SET LOCAL 事务作用域)— 偿还"竞态防护负价值"债
|
||||
|
||||
**Rationale:** 三位专家一致裁定:PP-07(无 FORCE)和 PP-08(共享池 SET/RESET 串扰)是同一架构缺陷两面——"数据库层做应用层的事"(SET 会话变量)和"应用层依赖数据库兜底但没真兜底"(无 FORCE)互相强化。安全专家"多一层防护更安全"的直觉在此是**负价值**:SET/RESET 在共享池上的毫秒级窗口产生的间歇性泄漏比"无 RLS 兜底"更危险,因为它制造了"有防护"的假象。正确做法是机制层消除窗口,而非"小心地 RESET"。
|
||||
|
||||
**Phases:**
|
||||
1. **Phase B1(FORCE RLS,0.5 周):** 新增迁移 `m000170` 遍历所有 tenant_id 业务表 `ALTER TABLE ... FORCE ROW LEVEL SECURITY`;新建非 owner 应用角色 `app_user`(REVOKE 所有 + GRANT CRUD + LOGIN),运行时连接改用 app_user——否则 FORCE 对 owner 无效。
|
||||
2. **Phase B2(SET LOCAL 事务作用域,1.5 周):** 废弃 `tenant_rls.rs:31` 共享池 `SET app.current_tenant_id` 反模式;重构中间件为 `db.transaction(|txn| async { txn.execute(SET LOCAL app.current_tenant_id = $1); next.run(req).await })`——`SET LOCAL` 作用域严格限于事务内,commit/rollback 后自动消失,物理上不可能被连接复用读到。同步修订 wiki §4 RLS 描述(消除文档代码漂移)。
|
||||
3. **Phase B3(并发隔离测试,0.5 周):** 新增多租户测试 crate(wiki §4.1 规划未实现),`#[tokio::test(flavor="multi_thread", worker_threads=8)]` 并发 100 个不同 tenant 请求断言零泄漏,覆盖 FHIR `allowed_patient_ids` 复杂范围过滤端点。
|
||||
|
||||
- **effortEstimate:** 2.5 周
|
||||
- **expectedImpact:** 从机制层消灭跨租户泄漏窗口(医疗 SaaS 合规红线)+ 为读写分离/PgBouncer transaction pooling 铺路(SET LOCAL 是唯一可用租户上下文传递方式)
|
||||
- **kpis:** 并发隔离测试 100 线程零泄漏;FORCE RLS 覆盖所有 79 业务表;连接池 max_connections 调优后无等待超时
|
||||
- **dependencies:** 连接池利用率评估(事务包裹增加连接占用时长,可能需调大 max_connections);只读端点统一事务路径回归测试
|
||||
- **调和分歧:** 采纳首席架构师 A 方案 + 后端/数据架构师的 SET LOCAL——不删除 RLS 中间件(保留 tenant_id 注入 extension),但删除共享池 SET/RESET,改为事务内 SET LOCAL。这是机制层根治,安全专家"多一层防护"诉求由 FORCE RLS 兜底满足,竞态窗口由 SET LOCAL 消除。
|
||||
|
||||
### 举措 C:模块边界复位(Saga 下沉 + ErpModule trait 裂变)— 偿还"组装层业务泄漏"债
|
||||
|
||||
**Rationale:** `erp-server/src/dialysis_workflow.rs` 在 L3 组装层承载透析业务编排,违反 §1.3"L2 间零直接依赖"铁律。ErpModule trait 11 个方法中 `register_event_handlers` 在 8 模块里 4 空壳,是典型的"接口名实不符"——新人会误以为事件注册走这个方法。这两点是微服务化时最痛的债:如果 erp-dialysis 核心逻辑物理上无法脱离 erp-server 运行,"按模块拆服务"就是空话。
|
||||
|
||||
**Phases:**
|
||||
1. **Phase C1(编排下沉,1.5 周):** `erp-core` 定义 `trait DomainSaga { fn name(); async fn handle(&event, &ctx); }` + `SagaContext { db, event_bus }`;把 `dialysis_workflow.rs` 的 `handle_dialysis_record_created` 整体迁移到 `erp-dialysis/src/sagas/dialysis_session_saga.rs`,由 erp-dialysis 在 `on_startup` 自注册到 SagaRegistry;同理 erp-health 的"告警→AI→推送"链路、erp-ai 的"化验上传→解读→推送"链路各归其位。**验收:** `erp-server/Cargo.toml` 不再直接依赖业务 entity crate 内部类型。
|
||||
2. **Phase C2(trait 裂变,1 周):** 废弃 `register_event_handlers`(deprecate 一版后删除),事件订阅统一在 `on_startup`;替换为 `fn capabilities(&self) -> &'static [ModuleCapability]` 显式能力声明(HttpRoutes / EventConsumer / EventPublisher / BackgroundWorker);按需 impl 而非大杂烩 trait。可选把单一 trait 裂变为 LifecycleModule / RoutingModule / EventModule(首席架构师提案),但为控制 breaking change 范围,建议先做能力声明,裂变留待 V2。
|
||||
3. **Phase C3(archlint,0.5 周):** `cargo xtask archlint` 扫描 `erp-server/src/` 下是否有跨 crate 业务 import,CI 拦截边界泄漏回归。
|
||||
|
||||
- **effortEstimate:** 3 周
|
||||
- **expectedImpact:** 模块具备"可独立部署"属性(微服务化前置)+ 消除 erp-server 业务逻辑认知陷阱 + 死 trait 方法清理降低新人认知负载
|
||||
- **kpis:** erp-server 不再 import 业务 entity crate;archlint CI 零违规;register_event_handlers 调用点清零
|
||||
- **dependencies:** Saga 间状态机/补偿事务本次只做"位置下沉"不引入完整 Saga 框架(避免过度设计);erp-dialysis 若需依赖 erp-workflow entity,通过 erp-core trait 反向依赖反转
|
||||
|
||||
### 举措 D:事件契约治理(Schema 注册表 + Consumer Manifest + CI 校验)— 偿还"v1 永冻"债
|
||||
|
||||
**Rationale:** 51 个事件类型声明在 `docs/event-registry.md`,但代码侧无强约束。`events.rs:67` 的 schema_version 仅写入 payload,无消费者校验。这正是 PP-01/PP-05(事件无消费者/死存储)反复出现的元根因——没有编译期/CI 约束,全靠人记忆。微服务化前不补这课,拆服务当天就要停机重写(微服务间事件 schema 演进是分布式系统头号痛点)。
|
||||
|
||||
**Phases:**
|
||||
1. **Phase D1(注册表,1 周):** `erp-core` 新增 `EventDescriptor { event_type, schema_version_range, payload_json_schema, deprecated_since }` 静态注册表;每个 crate 通过 `inventory` crate(编译期收集,fallback `build.rs` 代码生成)自动注册本模块发布/消费的事件描述符,生成 consumer manifest。
|
||||
2. **Phase D2(消费校验,1 周):** `consume_with_retry` 入口按 event_type 查表,schema_version 做语义版本范围匹配(`^v1` 兼容 v1.x),不匹配事件进新表 `incompatible_events` 并告警(不进 dead_letter);为 `health.patient.created` / `health.alert.triggered` / `article.published` 等核心事件补 JSON Schema 草稿(`jsonschema` crate 校验,采样率控制 ~0.1ms/事件开销)。
|
||||
3. **Phase D3(CI 拦截,0.5 周):** `cargo run --bin verify-event-contracts` 校验每个被 enqueue 的事件必须有至少一个 consumer manifest(直接拦截 PP-05 类死存储回归)。
|
||||
|
||||
- **effortEstimate:** 2.5 周
|
||||
- **expectedImpact:** 从文档约定升级为编译期可校验的类型契约 + CI 直接拦截"事件无消费者"回归 + 为微服务间 schema 演进(expand-contract)打地基
|
||||
- **kpis:** 51 事件类型 100% 有 consumer manifest;CI 零"无消费者"违规;核心事件 JSON Schema 覆盖率 > 60%
|
||||
- **dependencies:** 举措 A(spawn_workers 接线后才有真实消费者可 manifest);inventory crate 构建稳定性需验证
|
||||
|
||||
### 举措 E:可演进 schema 与数据生命周期(独立 migrate + 分区自动化 + 物化视图 + 归档)
|
||||
|
||||
**Rationale:** `main.rs:233` `Migrator::up` 在应用启动路径执行(PP-11),破坏性 DDL 在启动瞬间跑,多副本并发迁移会冲突;device_readings 分区硬编码到 2026-08(PP-02 确定性硬截止);stats_service 全是实时 count + date_trunc,Dashboard 随患者量增长拖垮 DB CPU;dead_letter_events / ai_analysis_queue / audit_logs / domain_events 无归档只增不减。这是 V1 上线后 6-12 个月迭代速度的核心瓶颈。
|
||||
|
||||
**Phases:**
|
||||
1. **Phase E1(分区自愈 + 死信索引,1 周,含确定性硬截止解除):** 立即补丁迁移 `m000170` 用 `generate_series` 动态补建 2026_06 起未来 12 个月分区(模板提取自 `m000073:42-55`);安装 pg_partman 5.x + `part_config(premake=3, infinite_time_partitions=true)`;erp-health `spawn_workers` 新增 `start_partition_maintenance`(每 6h `SELECT partman.run_maintenance('device_readings')` 应用层兜底,防 pg_cron 缺失)。同步给 `dead_letter_events` 补 `idx_dead_letter_unresolved ON (tenant_id) WHERE resolved_at IS NULL` + `idx_dead_letter_created`(配合举措 A 重试扫描性能)。
|
||||
2. **Phase E2(物化视图 + 归档,1.5 周):** stats 高频查询建物化视图 `mv_patient_stats_by_tenant` / `mv_consultation_stats_by_tenant`,`REFRESH CONCURRENTLY` 每 10 分钟(`tasks.rs` 新增 `start_stats_refresh`);Dashboard 读物化视图,UI 标注"数据更新于 X 分钟前"管理预期。归档函数 `archive_old_rows(table, days)` 把 > 180 天 resolved/completed 数据迁到 `_archive` 表(医疗病历留存 15 年合规要求,冷数据不删除只迁移);明确热(<30 天)/温(30-180 天)/冷(>180 天)三级生命周期写入 `wiki/database.md`。
|
||||
3. **Phase E3(独立 migrate 子命令,1 周):** 拆出 `erp-server migrate` 子命令,pre-deploy 阶段独立执行迁移,应用启动不再跑 `Migrator::up`;建立 schema 兼容窗口规则——破坏性变更必须分两次发布(v1 加新列双写 / v2 删旧列,expand-contract pattern),CI lint 强制。
|
||||
4. **Phase E4(双副本蓝绿,3-4 周,演进式不一步到位):** compose + 双副本 + Nginx upstream 切换 + PG 副本(`alerts.yml:70` pg_replication_lag 告警已定义但无副本);配合举措 A 的 PG advisory lock 选主。**异见采纳:** 医疗 SaaS 早期团队(<10 人)K8s 运维成本超过收益,compose + 双副本拿 80% HA/回滚收益用 20% 复杂度。
|
||||
|
||||
- **effortEstimate:** 6.5 周(E1-E3 可独立交付,E4 演进式)
|
||||
- **expectedImpact:** 解除 2026-09-01 确定性硬截止 + Dashboard DB CPU 降 50%+ + 破坏性迁移可安全回滚 + 为多租户 SaaS 报表打地基
|
||||
- **kpis:** device_readings 分区自动维护至 2027_06;Dashboard 物化视图刷新延迟 < 10min;破坏性迁移 100% 走 expand-contract;erp-server migrate 独立子命令上线
|
||||
- **dependencies:** 举措 A(spawn_workers 注册 partition_maintenance / stats_refresh);双副本引入分布式问题需配套选主;团队接受 expand-contract 两次迁移纪律(CI lint 强制)
|
||||
|
||||
---
|
||||
|
||||
## 四、速赢(1-2 周可落地)
|
||||
|
||||
1. **PP-01 死信重试接线(2-3 天):** 在 `tasks.rs` 新增 `start_dead_letter_retry`(每小时,调用 `retry_dead_letters` 并 touch heartbeat),与 `start_event_cleanup` 对称注册到 `main.rs`;补集成测试;修正 wiki 误记。**ROI 极高**——代码已实现 90%,差最后 10% 接线,解锁危急值告警/积分发放/预约提醒重试链路。
|
||||
2. **PP-02 分区补建迁移(2 天):** 立即写 `m000170` 用 `generate_series` 补建 2026_09~2027_06 分区(确定性硬截止解除,距今 ~10 周)。pg_partman 自动化可随后跟进,但手动补建是上线前必须项。
|
||||
3. **PP-07 FORCE RLS 迁移(1 天):** 单个迁移 `ALTER TABLE ... FORCE ROW LEVEL SECURITY` 遍历所有 tenant_id 表(沿用 m000088 DO 块模板),立即堵死 owner-bypass 路径。SET LOCAL 事务改造(举措 B Phase B2)随后跟进。
|
||||
4. **register_event_handlers 死方法清理(1 天):** deprecate 标注 + 文档迁移到 on_startup,低风险高回报的认知债偿还。
|
||||
|
||||
---
|
||||
|
||||
## 五、主题级风险
|
||||
|
||||
1. **多副本引入分布式问题:** 蓝绿/双副本后,后台任务(举措 A)会重复执行,需 PG advisory lock 选主;session 一致性、token 黑名单(当前 DashMap 非分布式)需迁 Redis。建议举措 A/E4 打包推进。
|
||||
2. **trait 裂变是 breaking change:** ErpModule trait 裂变需一次性迁移 8 个模块,建议放在 V1 上线后第一个迭代;archlint 误报可能拖慢迭代,需白名单机制。
|
||||
3. **SET LOCAL 性能开销:** 每请求 BEGIN/COMMIT 约 1-2ms,医疗后台可接受,但 device_readings 高频写入路径需评估;连接池利用率可能下降,需调大 max_connections。
|
||||
4. **加密与搜索契约冲突:** 患者姓名加密(PP-12)会破坏 `Name.contains`(ILIKE '%x%')模糊搜索契约,FHIR handler 依赖此契约。gin(trgm) 索引在加密后失效,必须在加密方案落地前确定搜索降级策略(prefix-only / HMAC 盲索引精确匹配),而非事后补救。
|
||||
5. **inventory crate 构建稳定性:** 某些构建配置下不稳定,需 fallback 到 build.rs 代码生成;JSON Schema 校验增加每事件 ~0.1ms 开销,需采样率控制。
|
||||
6. **确定性 vs 概率性优先级分歧:** 数据架构师主张 PP-02(确定性硬截止)优先级高于一切概率性 bug;其他专家按影响面排序。**裁定:** Phase 0 两者都做(速赢 1+2 并行),不排序。
|
||||
|
||||
---
|
||||
|
||||
## 六、调和专家分歧后的最终取舍
|
||||
|
||||
| 分歧点 | 安全/DevOps 立场 | 首席/后端/数据架构师立场 | 最终取舍 |
|
||||
|--------|-----------------|----------------------|---------|
|
||||
| tenant_rls SET 逻辑 | "多一层防护更安全,保留 SET" | "竞态防护是负价值,SET/RESET 窗口比无兜底更危险" | **采纳机制层根治:** FORCE RLS 兜底 + SET LOCAL 事务作用域,删除共享池 SET/RESET。安全诉求由 FORCE 满足,竞态由 SET LOCAL 消除 |
|
||||
| CD 工具选型 | "直接上 Kubernetes/ArgoCD" | "K8s 运维成本超过收益,compose + 双副本够用" | **采纳演进式:** compose + 双副本 + 独立 migrate,拿 80% 收益用 20% 复杂度。真正难的是 schema 兼容窗口纪律(expand-contract),与工具无关 |
|
||||
| 事件 schema 注册表 | "过度工程,补测试更重要" | "PP-01/PP-05 反复出现的元根因,微服务化前必修" | **采纳注册表:** 但分阶段(注册表 → 消费校验 → CI 拦截),先解决"无消费者"回归,JSON Schema 全覆盖可渐进 |
|
||||
| Repository trait 层 | — | 后端架构师主张抽出(消灭漏 tenant_id 再生土壤) | **暂缓:** 工作量大(16+ 处 begin/commit 迁移),且当前 SeaORM Filter + 举措 B 机制层防护已大幅降低泄漏风险。列为 V2 候选,先做试点(appointment/consultation/follow_up)验证抽象价值 |
|
||||
| trait 裂变 vs 能力声明 | — | 首席主张三 trait 裂变,后端主张能力声明 | **采纳能力声明优先:** 先 `capabilities()` 显式声明 + deprecate 死方法,裂变留待 V2,控制 breaking change 范围 |
|
||||
|
||||
---
|
||||
|
||||
## 七、路线图(与决策简报 Phase 对齐)
|
||||
|
||||
| Phase | 时窗 | 本主题举措 | 交付价值 |
|
||||
|-------|------|-----------|---------|
|
||||
| Phase 0 护航 | 0-2 周 | 速赢 1-4(PP-01 接线 / PP-02 分区补建 / PP-07 FORCE RLS / 死方法清理) | 消灭确定性硬截止 + 兑现死信重试承诺 + 堵死 owner-bypass |
|
||||
| Phase 1 稳固 | 1-3 月 | 举措 A 全量 + 举措 B 全量 + 举措 D Phase D1-D2 | 后台任务可观测 + 多租户机制层根治 + 事件契约注册表 |
|
||||
| Phase 2 深化 | 3-6 月 | 举措 C 全量 + 举措 D Phase D3 + 举措 E Phase E1-E3 | 模块边界复位 + CI 拦截无消费者 + schema 兼容窗口纪律 |
|
||||
| Phase 3 规模化 | 6-12 月 | 举措 E Phase E4(双副本蓝绿)+ Repository trait 试点 | 多副本 HA + 数据访问契约接缝(微服务化前置) |
|
||||
|
||||
---
|
||||
|
||||
> 本章节所有论断已附代码证据(文件:行号或 grep 结果)。核心架构债(事件契约 / 模块边界 / 后台任务 / 多租户隔离 / schema 演进)现在偿还成本是"重构级",6-12 个月后微服务化启动时将退化为"重写级"。决策层建议:Phase 0 速赢立即启动,Phase 1-2 把五类结构性缺陷升级为编译期/CI 可校验契约。
|
||||
155
docs/discussions/2026-06-25-analysis/03-ai-depth.md
Normal file
155
docs/discussions/2026-06-25-analysis/03-ai-depth.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 主题 03 — AI 智能化纵深
|
||||
|
||||
> 日期: 2026-06-25 | 分支: feat/media-library-banner | 主题负责人: 综合主持
|
||||
> 范围: V1 上线后 6-12 个月,从「被动分析工具」跨越到「AI 驱动的主动关怀引擎」
|
||||
> 证据基准: 所有论断附文件路径:行号或 grep 结果;关键代码断言已逐一核验
|
||||
|
||||
---
|
||||
|
||||
## 一、主题愿景(专家共识融合)
|
||||
|
||||
HMS 当前已具备相当完整的 AI 工程拼图——4 个 Provider(Claude/OpenAI/Ollama/通义)+ ReAct Agent 运行时(AgentOrchestrator + 9 Tool + 角色沙箱)+ RAG 知识库 V2 + 引用溯源 JSONB + 分析队列 + 成本/配额服务。**缺的不是新零件,而是电路闭合**:Agent 只在用户敲回车时转一次,永不主动跑;队列只入队不消费;成本看板是硬编码常量估算的假数据;RAG 检索质量全仓零指标(grep `recall_at_k/faithfulness/golden_dataset` 0 命中)。
|
||||
|
||||
**统一愿景**:以「闭合已存在但空转的能力」为第一性原理,分三步把 HMS 从「医生敲回车才出报告的分析工具」进化为「感知→推理→行动→复盘」的主动关怀引擎——
|
||||
|
||||
1. **通电**:打通事件→入队→自主 ReAct→结构化建议→回写档案→推送的全链路(修复 PP-05 死存储)。
|
||||
2. **可信**:用双层记忆解决金鱼记忆、用引用溯源闭环 + RAG 评估集让 AI 输出从「主观判断」变「可量化、可溯源、可反馈」。
|
||||
3. **可度量**:把真实 token 成本、建议转化率、检索 recall@5 变成运营仪表盘,让「AI 到底准不准/贵不贵/有没有用」可回答。
|
||||
|
||||
**北极星指标从「分析产出量」改为「AI 建议转化率」**——一个 insight 若无法对应任何临床动作,就不该生成。
|
||||
|
||||
---
|
||||
|
||||
## 二、专家提案摘要(异见与共识)
|
||||
|
||||
### 共识(三位专家高度一致)
|
||||
- **PP-05 是第一优先**:三位专家都把「claim_next 接线 + worker loop」列为头号提案,认定它是兑现「主动关怀」承诺的断点。
|
||||
- **claim_next:98 的 `format!` 拼 tenant_id 必须先参数化**(虽然 Uuid 不可利用,但违反 CLAUDE.md SQL 注入铁律 + 反模式)。
|
||||
- **自主 AI 必须接配额/熔断护栏**,否则 Provider 成本失控。
|
||||
- **评估闭环(RAG 黄金集 / LLM-judge)是补 PP-04 可观测性 + PP-10 测试覆盖在 AI 子系统的专门补丁**。
|
||||
|
||||
### 关键异见(最终取舍见 §六)
|
||||
| 分歧 | AI 架构师 | 医疗产品经理 | MLOps 工程师 | 最终取舍 |
|
||||
|------|-----------|--------------|--------------|----------|
|
||||
| AI worker 与死信重试是否合并 | 合并打包修(同类问题) | 分开(SLA/迭代节奏不同) | 分开但共享 spawn 骨架 | **分开 + 共享骨架**:采纳产品/MLOps 观点,业务可降级与基础设施 100% 投递不可混;但抽取通用 spawn+interval+shutdown 框架避免造轮子。 |
|
||||
| 患者侧是否上 LLM 对话 | 不主张 | 坚决反对(中老年接受度低) | 不涉及 | **不上**:患者侧坚持 local_rules + 模板,LLM 算力留给医护端决策支持。 |
|
||||
| 患者记忆卡片存储方式 | 结构化 KV 不全加密 | 不涉及 | 不涉及 | **结构化 key + 敏感 value 加密**:key(如 `medication_adherence:poor`)明文可查可审计,value 走 AES-256-GCM;自由文本不入库降低泄露面。 |
|
||||
| LLM-judge 可靠性 | 需人工抽审 | 引用校验优先 | 三层防线(规则兜底+LLM-judge+人工5%) | **三层防线**:关键医学禁用词走规则引擎硬门禁,ambiguous 走 LLM-judge,每周抽 5% 人工复核校准 judge。 |
|
||||
| 商业化时机 | 不涉及 | 等 KPI 跑通 2-3 个月再议 | 先堵漏再扩面 | **延后商业化**:建议转化率稳定前不按次计费。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、战略举措(5 项)
|
||||
|
||||
### 举措 1 — AI 分析队列消费者落地(堵 PP-05,闭合死存储)
|
||||
|
||||
**rationale**:`claim_next`(analysis_queue.rs:92)已实现 `FOR UPDATE` 语义但全仓仅定义处命中,module.rs:268 只实例化队列、无 worker 调用。三位专家一致认定的第一优先。
|
||||
|
||||
**phases**:
|
||||
- **P0(接线,1 周)**:①analysis_queue.rs:96-110 `format!` 拼 tenant_id 改为 `Statement::from_sql_and_keys` 参数化(修 SQL 注入铁律违反);②补 `FOR UPDATE SKIP LOCKED` 实现多 worker 并发安全 claim;③在 module.rs register 关闭阶段 spawn `start_analysis_queue_worker`,interval 15s,`SELECT DISTINCT tenant_id` 驱动多租户遍历,并发度从 config.toml 读取(默认 2)+ `tokio::sync::Semaphore` 限流。
|
||||
- **P1(链路闭合,2 周)**:复用 chat_handler.rs:182-192 的 9 Tool 注册逻辑(抽 `build_default_tool_registry()` 公共函数),按 analysis_type 分派,调用 `AgentOrchestrator::run()`,结果走 `post_process_analysis()` 写 ai_analysis + 发布 `ai.analysis.completed` 事件(消费者 erp-message 已就绪)→ 转 `copilot_insights` 行进医护侧边栏行动收件箱。
|
||||
- **P2(韧性,1 周)**:`started_at < NOW()-30min AND status='running'` 的卡死回收扫描 + 失败走 mark_failed(retry_count<max_retries=3 指数退避,达限移入死信)+ 同患者同 insight_type 24h 去重防洪泛。
|
||||
|
||||
**effortEstimate**:4 周(后端 1 人)
|
||||
**expectedImpact**:高 — 兑现「主动关怀」承诺;激活 5 条入队链路(critical_alert/lab_upload/dialysis/patrol/high_risk)从死存储变活管线。
|
||||
**kpis**:队列 pending 积压 ≤50;worker 吞吐 ≥100 分析/天/租户;p95 分析延迟 ≤30s;卡死回收覆盖率 100%;死信率 ≤2%。
|
||||
**dependencies**:PP-01 死信重试接线(共享 spawn 骨架);config.toml 并发度字段;Provider 配额服务(举措 4)。
|
||||
|
||||
### 举措 2 — Function Calling + 双层长期记忆(从文本到可执行建议)
|
||||
|
||||
**rationale**:FC 已在 claude.rs/openai.rs 实现(`generate_with_tools`),但仅 chat_handler 使用;Agent 永不主动跑、记忆仅最近几条原文(chat_handler.rs:140 加载历史,长会话 token 爆炸)。
|
||||
|
||||
**phases**:
|
||||
- **P1(FC 接入分析,3 周)**:AnalysisService::stream_analyze 注入受控工具 schema(fetch_lab_trends/fetch_medication_history/lookup_drug_interaction/lookup_clinical_guideline/propose_followup_action),LLM 发起 tool_call → worker 本地执行 → 结果回灌 → 输出结构化 JSON `{summary, findings[], suggested_actions[], confidence}`;max_tool_rounds=5 + token budget 熔断;扩 analysis.rs references JSONB 存 findings/suggested_actions。
|
||||
- **P2(双层记忆,3 周)**:①会话级滚动摘要——session>20 条触发 `summarize_session`(cheap Ollama provider),摘要存 ai_chat_session.metadata(JSONB 已存在,零迁移),chat_handler:140 优先注入摘要+最近4条,token 从 ~8k 降到 ~1.5k;②患者级事实卡片——新增 `ai_patient_memory` 表(patient_id/tenant_id/key/value/source/confidence/expire_at),key 明文、value 加密,post_process 用 LLM 抽取结构化事实 upsert(confidence 阈值 0.7)。
|
||||
- **P3(PII 防护,1 周)**:tool_call 结果注入 LLM 前经 SanitizationService 脱敏(姓名/身份证/手机号 token 化为 `patient_ref_xxx`);Provider 配置层启用 zero-retention;扩 AuditLog 记录所有记忆写入。
|
||||
|
||||
**effortEstimate**:7 周(后端 1 人 + 医疗审校 0.3 人)
|
||||
**expectedImpact**:高 — AI 输出从自然语言段落变可机读临床建议;长会话成本降 ~80%;Agent 跨会话记忆患者基线。
|
||||
**kpis**:结构化输出占比 ≥90%;会话平均 token 降幅 ≥60%;记忆卡片过期率(30 天)≤15%;PII 泄露事件 0。
|
||||
**dependencies**:举措 1(worker 复用 FC 链路);举措 4(能力矩阵 + token budget);knowledge_v2 检索质量(举措 3)。
|
||||
|
||||
### 举措 3 — RAG 评估闭环 + 引用可信度(让 AI 可量化)
|
||||
|
||||
**rationale**:全仓 grep `recall_at_k/faithfulness/golden_dataset` 0 命中——今天无人能回答「我们的 AI 检索到底准不准」;references JSONB 已加但前端仅文本展示、无溯源跳转、无反馈。
|
||||
|
||||
**phases**:
|
||||
- **P1(引用溯源闭环,2 周)**:①worker 完成分析时做引用完整性校验——`[ref:id]` 必须全部能在 ai_knowledge_references 查到,查不到标 broken_citation 并降级 confidence(前端灰显);②前端 `[ref:xxx]` 渲染为可点击 chip 弹 guideline 摘要;③suggestion_feedback 扩 `useful_citation`/`wrong_citation` action;④nightly 统计 knowledge_reference 采纳率,低采纳标需复核。
|
||||
- **P2(评估集,3 周)**:新增 `ai_eval_case` 表(input_payload/expected_topics/critical_must_mention/must_not_mention/golden_summary/source_analysis_id),初始从医生反馈正向记录导入 20-50 条覆盖高血压/糖尿病/透析高频场景;新增 `eval_runner.rs` 跑 active prompt 计算 recall@5/citation_precision/answer_faithfulness。
|
||||
- **P3(CI 门禁,2 周)**:CI 加 `cargo test --features eval-smoke` 跑 10% 子集(无 LLM 的纯 recall@5,快)作为 PR 门槛,分数下降超阈值阻断;全量 LLM-judge 评测手动触发,写 docs/audits/rag/rag-eval-YYYY-MM-DD.md 对比趋势。
|
||||
|
||||
**effortEstimate**:7 周(后端 1 人 + 医护标注 0.5 人)
|
||||
**expectedImpact**:中 — AI 质量从主观判断变数字仪表盘;驱动知识库持续迭代;改 prompt 后能回答「新版有没有变好」。
|
||||
**kpis**:recall@5 ≥0.8;citation_precision ≥0.95;broken_citation 率 ≤3%;评估集季度刷新 1 次;CI 门禁拦截率可观测。
|
||||
**dependencies**:knowledge_v2 文档质量;医护标注时间投入;vector_search.rs:52 参数化改造(顺带完成)。
|
||||
|
||||
### 举措 4 — 成本真相化 + Provider 能力矩阵(可观测 + 不爆费)
|
||||
|
||||
**rationale**:`default_token_estimate`(cost.rs:40-49)硬编码 2000/1500 等常量,非 chat 分析路径完全跳过 `log_usage`(仅 chat_handler.rs:319 + mod.rs:1116 调用)——成本看板是估算假数据;`token_budget: None` 永远不启(orchestrator.rs:130 是死代码);`supports_fc = provider_name != "ollama"`(chat_handler.rs:241)硬编码字符串。
|
||||
|
||||
**phases**:
|
||||
- **P1(真实 usage,2 周)**:①provider 返回 TokenUsage 上抛(扩 AnalysisResult 结构,逐个适配 chat/copilot/agent 调用方);②analysis.rs 收尾调 `UsageService::log_usage`;③CostService::estimate_cost 优先读 ai_usage 真实聚合、缺数据降级估算并标 `is_estimated`;④新增 `GET /ai/cost/realtime` + Prometheus 指标接 Grafana。
|
||||
- **P2(能力矩阵,2 周)**:AiProvider trait 加 `supports_function_calling()`/`supports_streaming()` 自报能力,chat_handler 按矩阵走 FC ReAct 或降级;token_budget 从 config 按角色配额(患者 8k/医生 16k/worker 4k)激活死代码;cost.rs 接实时熔断——单租户单日累计超阈值 chat_handler 返回 429 + worker 暂停入队。
|
||||
|
||||
**effortEstimate**:4 周(后端 1 人)
|
||||
**expectedImpact**:中 — 成本从谎报变真相;Provider 混用不空转不爆费;自主 AI 有预算护栏。
|
||||
**kpis**:成本看板 is_estimated 占比 ≤10%(新数据);租户日配额熔断触发可观测;Ollama 降级路径 tracing 可见;死代码 token_budget 激活。
|
||||
**dependencies**:Provider usage 字段稳定性(Ollama 可能不返回 usage,降级估算+记 `model_not_reporting_usage`);举措 1 worker(共享熔断)。
|
||||
|
||||
### 举措 5 — AI 行动闭环 + 患者侧轻量触达(建议不悬空 + 提升依从性)
|
||||
|
||||
**rationale**:suggestion_feedback 只存 action(accept/reject)无下游动作,AI 建议悬空;患者侧(中老年为主)对 AI 聊天接受度低,需「清晰的下一步动作」而非 AI 解读。
|
||||
|
||||
**phases**:
|
||||
- **P1(建议→任务转化,2 周)**:新增 `POST /ai/suggestions/{id}/convert`,按 suggested_action_type 调 erp-health service(follow_up_service::create_task_from_template 已存在)创建业务对象 → 发 `suggestion.converted` → 关联 task_id;前端漏斗视图「已采纳 N/已转化 M/已改善 K」。权限边界:convert 需 `require_permission` 对应 .manage,不绕过任务创建权限。
|
||||
- **P2(患者侧轻量触达,3 周)**:复用 local_rules.rs 扩「依从性规则集」(漏测/服药窗/复诊到期),每日扫描 device_readings 最近 3 天,命中生成 patient_facing insight(模板渲染非 LLM,成本低)→ `ai.insight.generated` → 小程序助手 Tab(注意修 PP-06 角标指向 index 3);限频每患者每天 ≤1 条 + 勿扰时段 + 一键关闭。
|
||||
- **P3(北极星度量,1 周)**:周报统计「建议→任务转化率」按 insight_type/suggestion_source 维度,作为 AI 价值核心 KPI。
|
||||
|
||||
**effortEstimate**:6 周(后端 1 人 + 前端 0.5 人 + 法务审文案 0.2 人)
|
||||
**expectedImpact**:中 — AI 价值从「产出量」转「转化率」;患者依从性可度量提升。
|
||||
**kpis**:建议转化率 ≥30%(高频随访模板补全后);患者推送退订率 ≤5%;依从性规则医学正确性(每条带 guideline_id 引用)。
|
||||
**dependencies**:PP-02 device_readings 分区续建(患者触达数据源);PP-06 告警角标修复;follow_up 模板补全;跨模块调 erp-health 走 trait 不直依赖(架构铁律)。
|
||||
|
||||
---
|
||||
|
||||
## 四、速赢(1-2 周可落地)
|
||||
|
||||
1. **claim_next 参数化 + SKIP LOCKED 加固**(≤3 天):analysis_queue.rs:96-110 `format!` 改 `Statement::from_sql_and_keys` + 补 `FOR UPDATE SKIP LOCKED`,消除 SQL 注入铁律违反 + 为多 worker 并发铺路。零业务风险,纯加固。
|
||||
2. **token_budget 死代码激活**(≤2 天):orchestrator.rs:130-148 预算逻辑已实现,仅需 chat_handler.rs:225 从 config 读取角色配额(患者 8k/医生 16k)传入,让既有护栏生效。
|
||||
3. **真实 usage 灌入非 chat 路径**(≤5 天):provider TokenUsage 上抛 + analysis.rs 收尾调 log_usage + CostService 标 is_estimated,让成本看板从假数据变真相。
|
||||
4. **引用完整性校验 MVP**(≤5 天):worker 完成分析时遍历 `[ref:id]` 校验存在性,broken 标记 + 降级 confidence(前端灰显),无需新表新迁移。
|
||||
|
||||
---
|
||||
|
||||
## 五、主题级风险
|
||||
|
||||
1. **自主 AI 成本失控**:worker 持续消耗 Provider 配额。→ 必须先接举措 4 配额/熔断再放举措 1 全量。
|
||||
2. **医疗 AI 幻觉危及患者**:结构化建议被医生采纳进入临床决策。→ 引用溯源闭环(举措 3)+ evidence_strength 灰显 + 前 2 周「只读不推」模式 + 法务审文案。
|
||||
3. **PII 通过 tool_call 外泄**:患者数据二次喂外部 Provider。→ SanitizationService 强脱敏 + Provider zero-retention + 记忆 value 加密。
|
||||
4. **LLM-judge 自身幻觉**:评估闭环把质量门禁交给可能幻觉的模型。→ 三层防线(规则硬门禁 + LLM-judge 仅 ambiguous + 人工 5% 抽审)。
|
||||
5. **AI 产出洪泛**:risk_service 每天对所有高危患者刷一遍会爆 insight 表。→ 同患者同 insight_type 24h 去重 + 只读不推过渡期。
|
||||
6. **患者信任度低**:中老年对机器提醒信任低于医生口头医嘱。→ 定位「辅助提醒」非「诊断建议」+ 文案法务审核 + 严格限频。
|
||||
7. **评估集冷启动 + 过期**:前 1-2 月反馈量不足;季度不刷新则失真。→ 初期只做引用完整性校验不做质量评分;建季度刷新机制。
|
||||
|
||||
---
|
||||
|
||||
## 六、专家分歧调和(最终取舍)
|
||||
|
||||
1. **AI worker vs 死信重试是否合并** → **分开 + 共享 spawn 骨架**。采纳医疗产品经理/MLOps 观点:基础设施层(死信 100% 投递、无业务语义)与业务层(AI 可降级、可限流、可灰度)的 SLA/迭代节奏/监控指标完全不同,强行合并会让 AI 业务被基础设施稳定性拖死。但抽取通用「spawn + interval + graceful shutdown」框架避免重复造轮子(反模式合规)。
|
||||
2. **患者侧 LLM 对话** → **不上**。三位中两位明确反对,符合「用户驱动非技术驱动」原则;患者侧坚持规则引擎 + 模板。
|
||||
3. **患者记忆存储** → **结构化 key 明文 + 敏感 value 加密**。调和安全(可审计/可查询)与隐私(PII 保护),自由文本不入库。
|
||||
4. **LLM-judge 可靠性** → **三层防线**。规则引擎兜底关键医学禁用词,LLM-judge 仅处理 ambiguous,人工抽审 5% 校准。
|
||||
5. **RAG 评估 vs 补 E2E 优先级** → **并行**。AI 输出正确性(举措 3)与 UI 可点性(属测试主题)风险类型不同,医疗幻觉比少几个 E2E spec 更致命,但两者不互斥,分头推进。
|
||||
6. **商业化时机** → **延后**。等建议转化率 KPI(举措 5)跑通 2-3 个月、医护采纳率稳定后再议按次计费。
|
||||
|
||||
---
|
||||
|
||||
## 七、路线图(与项目 9 维度 Phase 对齐)
|
||||
|
||||
- **Phase 1(1-3 月)**:举措 1(P0+P1 接线闭合)+ 举措 4(P1 成本真相化)+ 速赢 1-3。兑现「至少 1 条健康告警→AI 评估→推送」完整链路。
|
||||
- **Phase 2(3-6 月)**:举措 1(P2 韧性)+ 举措 2(FC + 双层记忆)+ 举措 3(P1 引用闭环 + P2 评估集)+ 举措 5(P1 建议转化)。从「被动工具」跨到「主动关怀引擎」。
|
||||
- **Phase 3(6-12 月)**:举措 3(P3 CI 门禁)+ 举措 4(P2 能力矩阵)+ 举措 5(P2 患者触达 + P3 北极星度量)+ Prompt 影子发布/A-B(利用 CacheKey 含 prompt_version 红利,红利已核验 cache.rs)。多模态(化验单图像识别)作为远期探索。
|
||||
|
||||
---
|
||||
|
||||
> 本主题所有代码断言已逐一核验:claim_next 未接线(grep 全仓仅定义处命中)、default_token_estimate 硬编码(cost.rs:40-49)、log_usage 仅 2 处调用(chat_handler.rs:319 + mod.rs:1116)、token_budget: None 死代码(orchestrator.rs:130)、supports_fc 硬编码(chat_handler.rs:241)、recall/faithfulness/golden 0 命中。论断可追溯。
|
||||
183
docs/discussions/2026-06-25-analysis/04-multidevice-ux.md
Normal file
183
docs/discussions/2026-06-25-analysis/04-multidevice-ux.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 多端体验统一 — 主题综合
|
||||
|
||||
> 日期: 2026-06-25 | 分支: feat/media-library-banner | 主题负责人: 综合主持
|
||||
> 范围: V1 上线后 6-12 个月,跨 Web / 小程序 / 适老化 / 无障碍的多端体验治理
|
||||
> 证据基线: 所有论断附文件路径:行号,已逐项核验(见文末附录 A)
|
||||
|
||||
---
|
||||
|
||||
## 一、主题愿景 (Vision)
|
||||
|
||||
**以"行为契约一致"取代"像素级对齐"作为多端统一的度量:** 同一份交互契约、同一份设计 Token 源、同一类用户在任何端完成同等质量的操作。视觉层尊重三端语境差异(PC 鼠标精确点击 vs 手机触控 vs 老人手抖),但**状态语义层(错误/空/加载/危急)必须跨端一致**——同一类危急值告警在三端都拥有图标、朗读、重试与多通道触达。
|
||||
|
||||
当前 HMS 的多端体验是"4 处 token 副本 + 2 套请求层 + 隐式导航结构 + Web 无障碍盲区"的离散态:色值在 `variables.scss:5`(#C4623A 橙)、`App.tsx:153/175/194/218`(4 套硬编码 themeConfigs)、`index.css:12`(#2563EB 蓝)、`token-values.ts` 四处平行复制且已发生漂移;Web 与小程序的缓存 TTL(5s vs 60s)、错误映射(无 vs 有)、Token 刷新(预检 vs 响应式)三处配置已发散。本主题的目标是**把"统一=改一处生效全端"从口号变为 CI 可校验的硬约束**,让设计系统从"审美问题"升级为"医疗可见性错误的根因防线"。
|
||||
|
||||
---
|
||||
|
||||
## 二、专家提案摘要
|
||||
|
||||
| 专家 | 核心切入点 | 关键提案 |
|
||||
|------|-----------|---------|
|
||||
| **设计系统架构师** | DTCG JSON 单一真相源 + 代码生成器分发三端 | DTCG tokens.json 单源 → Style Dictionary 生成 SCSS/CSS/antd themeConfigs;语义化导航契约根治 TabBar 硬编码索引;跨端语义组件契约(AlertCard/EmptyState);Web 适老化主题补盲;Figma-代码双向 sync |
|
||||
| **前端架构师** | @hms/shared 跨端内核 + 契约驱动 + 性能预算 | 抽错误码/缓存策略/Token 状态机三大不变量为共享包;token 单源不统一命名空间(--erp-* / --tk-* 双轨);Web Vitals 采集 + 性能预算门禁;路由表契约收敛 PP-09/PP-06;error_code enum + codegen |
|
||||
| **UX 研究员 / 无障碍专家** | 真实使用情境(医护效率 + 老年降级 + WCAG) | dashboardNavMap 意图层契约;危急值多通道触达(震动/语音/横幅);Design Token v2 含四态语义 + WCAG 对比度门禁;情境感知适老化(行为信号推断);键盘快捷键 + 撤销模式 |
|
||||
|
||||
### 三专家共识点(已收敛为举措基础)
|
||||
|
||||
1. **路由/导航契约化**是 PP-06 + PP-09 共同根因解(三方都提出,措辞不同:dashboardNavMap / NavigationContract / routeConfig 扩展)。
|
||||
2. **设计 Token 单源 + 生成器**取代手工多副本(三方一致,分歧仅在命名是否统一)。
|
||||
3. **危急值可达性 + WCAG 对比度门禁**是无障碍从主观转客观的唯一路径。
|
||||
4. **Web 适老化主题是系统性盲区**,需补齐(小程序 58/58 覆盖,Web 0)。
|
||||
|
||||
### 主要分歧(已在第四节调和)
|
||||
|
||||
- **token 命名空间**:设计系统架构师倾向 DTCG 语义命名重整,前端架构师主张保留双命名空间(--erp-* / --tk-*)只统一源。→ **采纳前端方案**(改名风险大,生成器桥接更安全)。
|
||||
- **共享包粒度**:前端架构师主张 packages/shared 全量抽象,设计系统架构师主张 packages/design-tokens 独立。→ **分两包但同 monorepo**:design-tokens 先行,shared 内核第二轮。
|
||||
- **token 跨端范围**:UX 专家主张视觉 token 各端独立 + 语义 token 共享,设计系统架构师倾向全量统一。→ **采纳 UX 方案**(蓝橙调色板本质不同不强行统一,只统一语义层)。
|
||||
|
||||
---
|
||||
|
||||
## 三、战略举措 (Initiatives)
|
||||
|
||||
### 举措 1:路由与导航契约层 — 根治 PP-09 死链与 PP-06 角标错位
|
||||
|
||||
**理由:** PP-09(4 Dashboard 6 条死链 navigate,`AdminDashboard.tsx:51/69` 指向 `/health/follow-ups`/`/health/vital-signs`,实际路由是 `follow-up-tasks` / 体征路由不存在)与 PP-06(`useAlertPolling.ts:64` 写死 `setTabBarBadge({index:2})`,但 `app.config.ts:80` index 2=商城,告警应到 index 3=助手)的共同根因是**路由/Tab 配置为运行时字符串字面量与裸数字索引,无单一契约源**。影响面 100% 角色、100% 用户、上线即暴露。
|
||||
|
||||
**分阶段落地点:**
|
||||
- **Phase 0(2 周):** 修 6 条死链 navigate + 清理 `AdminDashboard.tsx:88 value={healthDataStats ? 0 : 0}` 僵尸写法;`useAlertPolling.ts` 改 `resolveTabBarIndex('/pages/messages/index')` 动态查找。
|
||||
- **Phase 1(1 个月):** 在 `routeConfig.ts` 扩展 `dashboardNavMap`(业务意图键 → path + permissions + 后端统计字段);抽 `useDashboardCard(intent)` hook 按权限过滤卡片;CI 加 navigate 目标存在性单测(遍历 map 断言每个 path 在 routeConfig 存在)。
|
||||
- **Phase 2(3 个月):** `packages/shared-contracts/navigation.ts` 跨端 NavigationContract,Web 侧边栏 + 小程序 TabBar 都从契约读取;权限字段统一从契约派生。
|
||||
|
||||
**工作量估算:** Phase 0 约 2-3 人日;Phase 1 约 5-7 人日;Phase 2 约 10-15 人日(跨端 + 类型系统差异)。
|
||||
**预期影响:** 消灭医疗管理后台 403 死胡同与假数据展示,根除"TabBar 顺序一变就复发"的角标 bug 类。**高 / 中量**
|
||||
**KPI:** navigate 死链数 = 0(CI 门禁);TabBar 角标错位 bug 复发次数 = 0;权限过滤后无权卡片不再渲染(点击前拦截率 100%)。
|
||||
**依赖:** 后端菜单 API(Phase 2 才需扩展 path 字段);routeConfig 现有结构。
|
||||
|
||||
### 举措 2:设计 Token 单源 + 代码生成器(DTCG JSON)
|
||||
|
||||
**理由:** 当前色值/字号在 4 处平行复制且已漂移:`variables.scss:5`(#C4623A 橙)、`App.tsx:153-218`(4 套硬编码 colorPrimary)、`index.css:12`(#2563EB 蓝)、`token-values.ts`。Web 完全缺失适老化主题与 motion/a11y token。纯 SCSS 无法跨仓库收敛,已发生手工漂移。
|
||||
|
||||
**分阶段落地点:**
|
||||
- **Phase 1(1 个月):** 新建 `packages/design-tokens/`(monorepo 首个共享包),DTCG JSON 为唯一源;Style Dictionary 生成三端产物(小程序 variables.scss + tokens.scss + token-values.ts;Web index.css :root + antd themeConfigs.ts);CI 加产物与源同步 diff 检查。**保留双命名空间**(--erp-* / --tk-*),只统一源不强行改名。
|
||||
- **Phase 2(3 个月):** 新增四态语义 token(`--tk-state-error/empty/loading/critical`) + Web 适老化 variant(controlHeight 40→52, fontSize 14→18)+ motion token(duration/easing 自动尊重 prefers-reduced-motion) + a11y token(focus-ring);Web 用户菜单增加"适老化"开关。
|
||||
- **Phase 3(6 个月):** Figma Tokens 插件双向 sync + 视觉回归(Playwright + miniprogram-automator 核心页面截图比对) + WCAG 对比度自动化门禁(正文/背景 ≥4.5:1,关怀模式 ≥7:1)。
|
||||
|
||||
**工作量估算:** Phase 1 约 8-10 人日(含 Style Dictionary 选型与构建集成);Phase 2 约 12-15 人日;Phase 3 约 15-20 人日(含视觉回归基线调优)。
|
||||
**预期影响:** 消灭 token 漂移(CI 硬约束),补齐 Web 适老化盲区,无障碍从主观评价转为 CI 红绿灯。**中 / 大量**
|
||||
**KPI:** token 副本数从 4 → 1(CI 门禁);Web 适老化主题覆盖 0 → ≥30 核心页面;WCAG 对比度门禁通过率 100%;token 变更影响面自动标注覆盖率 100%。
|
||||
**依赖:** pnpm workspace 接入;Antd ConfigProvider 对自定义 variant 的支持;团队 DTCG 命名培训。
|
||||
|
||||
### 举措 3:跨端语义组件契约层 — AlertCard / EmptyState / VitalCard
|
||||
|
||||
**理由:** 不追求像素级跨端一致(Ant Design vs 微信原生组件库是硬约束),而是为高频业务语义组件规定统一 TS 接口,让后端 DTO 字段名对齐组件 prop 名,减少 transform 层。优先 AlertCard(PP-06 危急值可见性)与 EmptyState(积分商城 Tab 空白 bug wiki 已记)。
|
||||
|
||||
**分阶段落地点:**
|
||||
- **Phase 1(1 个月):** 定义 `packages/shared-contracts/components.ts` 中 AlertCardProps(severity: critical|warning|info, title, value, action) + EmptyStateProps(icon, title, action);Web 与小程序各自实现但 satisfy interface(TS implements)。
|
||||
- **Phase 2(3 个月):** 后端 `alert_dto.rs` 等响应字段与契约对齐;Storybook(Web) + 组件 demo 页(小程序) 按契约生成 demo。
|
||||
- **Phase 3(6 个月):** 扩展到 VitalCard / ErrorBoundaryFallback / LoadingState / StatusTag 共 6 类;CI 跑视觉回归保证 prop 兼容。
|
||||
|
||||
**工作量估算:** Phase 1 约 5-7 人日;Phase 2 约 8-10 人日;Phase 3 约 12-15 人日。
|
||||
**预期影响:** 消灭两端各自重复造轮子(40+ 小程序组件与 Web Antd 隐式重复),减少前后端 DTO 不同步症状。**中 / 大量**
|
||||
**KPI:** 高频业务组件契约覆盖率(AlertCard/EmptyState 优先);前端 transform 层代码行数下降;DTO ↔ prop 字段对齐率。
|
||||
**依赖:** 举措 2 的 token 层;两端 StatusTag severity 枚举映射层(小程序 abnormal/critical vs antd error/warning)。
|
||||
|
||||
### 举措 4:危急值可达性 + 多通道触达(无障碍优先)
|
||||
|
||||
**理由:** 医疗安全信息必须多通道冗余(WCAG 2.2 Status Updates + IEC 62366 alarms 原则)。当前危急值仅靠单一角标(且写错 Tab),老年患者对延迟和字号容忍度更低。`hapticHeavy()` 已实现于 utils/haptic.ts 但未接入告警。
|
||||
|
||||
**分阶段落地点:**
|
||||
- **Phase 0(2 周):** 修 `useAlertPolling.ts:64` 角标 index bug(举措 1 联动);接入 hapticHeavy/medium。
|
||||
- **Phase 1(1 个月):** 多通道触达:(a) 触觉 haptic;(b) elder-mode 下 TTS 语音播报/预录提示音;(c) 页面顶部 sticky 红色横幅(不依赖 Tab 切换);elder-mode 自动缩短轮询间隔 10s → 5s + 放大横幅字号。
|
||||
- **Phase 2(3 个月):** Web 端补齐 aria-live:所有数据加载/错误/告警区域加 role=status aria-live=polite(critical 用 assertive);e2e mock 验证角标出现在正确 Tab。
|
||||
|
||||
**工作量估算:** Phase 0 约 1 人日;Phase 1 约 5-7 人日;Phase 2 约 4-5 人日。
|
||||
**预期影响:** 危急值不再被单一视觉通道埋没,视障医护可朗读工作台状态。**高 / 小量**
|
||||
**KPI:** Web aria-live 覆盖区域数(当前仅 1 处 PluginDashboardPage:448 → 目标 ≥20);危急值多通道触达覆盖率 100%;elder-mode 下告警延迟 ≤5s。
|
||||
**依赖:** 举措 1 的 TabBar 契约;微信小程序 TTS 插件能力(可能降级预录音频)。
|
||||
|
||||
### 举措 5:医护效率层 + 情境感知适老化(差异化体验)
|
||||
|
||||
**理由:** 医护 8 小时班次高频重复操作(确认告警/回复咨询/标记随访),每次点击 + modal + 确认造成腕劳损;elder-mode 完全依赖用户主动开启,真正需要它的老年患者往往不知道。
|
||||
|
||||
**分阶段落地点:**
|
||||
- **Phase 2(3 个月):** 键盘快捷键层(J/K 选择、Enter 详情、C 确认告警、/ 聚焦搜索,参照 Linear/GitHub);危险操作改"软删除 + 5 秒撤销"替代二次确认 modal;告警/随访批量操作;小程序医生端左滑快速完成/右滑推迟。
|
||||
- **Phase 3(6 个月):** `useAdaptiveMode()` hook 被动采集信号(连续校验失败 ≥2 次 / 触摸偏离中心 >40% / 停留时长超中位数 2 倍),顶部柔和提示邀请开启长辈模式(不静默改设置,信号只本地不上传);情感化关怀文案(异常体征不再冷冰冰,配合 AI 主动关怀)。
|
||||
|
||||
**工作量估算:** Phase 2 约 10-12 人日;Phase 3 约 12-15 人日。
|
||||
**预期影响:** 降低医护重复劳损与误操作,把适老化从"用户主动开启"升级为"系统主动识别并邀请"。**中 / 大量**
|
||||
**KPI:** 医护高频操作点击数下降 ≥30%;elder-mode 主动邀请转化率;撤销模式误操作回退成功率。
|
||||
**依赖:** 后端撤销 API(先纯前端 5 秒延迟窗口,后端分批补);隐私政策声明(行为信号采集);法务审核情感文案。
|
||||
|
||||
---
|
||||
|
||||
## 四、专家分歧调和(Dissenting Views → 最终取舍)
|
||||
|
||||
| 分歧 | 设计系统架构师 | 前端架构师 | UX/无障碍专家 | **最终取舍** |
|
||||
|------|--------------|-----------|--------------|------------|
|
||||
| token 命名空间 | DTCG 语义命名重整 | 保留双命名空间只统一源 | 视觉各端独立 + 语义共享 | **保留双命名 + 源统一 + 语义层共享**(改名风险大,语义层价值高) |
|
||||
| 共享包结构 | packages/design-tokens 独立 | packages/shared 全量抽象 | 跨端共享 token v2 | **design-tokens 先行 + shared 内核第二轮**(降低首轮风险) |
|
||||
| 跨端统一范围 | 全量统一 | 统一源不统一命名 | 视觉独立语义统一 | **视觉层各端独立,状态语义层跨端共享**(蓝橙调色板本质不同) |
|
||||
| 优先级之争 | — | 与 DevOps 争优先级(PP-01/02/04 vs 前端) | — | **PP-01/PP-02/PP-03 硬截止让路,PP-09/PP-06 并行**(影响面 100% 用户,修复成本低) |
|
||||
| 传输层抽象时机 | — | 先抽纯逻辑后抽传输层 | — | **采纳:第一轮只抽错误/缓存/状态机,axios vs Taro 延迟** |
|
||||
|
||||
---
|
||||
|
||||
## 五、速赢(Quick Wins,1-2 周可落地)
|
||||
|
||||
1. **修 PP-09 工作台 6 条死链 navigate + value={0} 僵尸写法**(`AdminDashboard.tsx:51` 改 `/health/follow-ups` → `/health/follow-up-tasks`;`:69` 删除或改真实体征路由;`:88` 删除 `value={healthDataStats ? 0 : 0}`)— 约 0.5 人日,影响面 100% 角色。
|
||||
2. **修 PP-06 告警角标写错 Tab**(`useAlertPolling.ts:64` index:2 → 按 `app.config.ts:81` pagePath `pages/messages/index` 动态查找)— 约 0.5 人日,根除"危急值被埋没在商城 Tab"。
|
||||
3. **接入 hapticHeavy/medium 到告警**(utils/haptic.ts 已实现未接线)— 约 0.5 人日,危急值触觉反馈。
|
||||
4. **Web Vitals 采集 MVP**(引入 web-vitals ~1KB,main.tsx 采集 LCP/CLS/INP,走现有 /analytics/batch 上报)— 约 1 人日,唯一不依赖后端可观测性落地的性能观测手段。
|
||||
|
||||
---
|
||||
|
||||
## 六、主题级风险(Risks)
|
||||
|
||||
1. **monorepo 引入增加构建复杂度**:packages/ 目录首次创建,pnpm workspace 接入需同步改 apps/web 与 apps/miniprogram 的 tsconfig paths;小程序对依赖体积敏感,shared 包须支持 tree-shaking 且零浏览器 API 依赖。**缓解:** 第一轮只抽纯逻辑层,传输层延迟;shared 包严格 side-effect free。
|
||||
2. **DTCG 标准对多主题支持尚在草案**:项目 Web 有 4 套主题,需用 $extensions 字段扩展,可能偏离标准。**缓解:** Phase 1 先用单主题 + variant 覆盖,多主题 Phase 2 再处理。
|
||||
3. **视觉回归基线维护成本高**:初始会有大量误报需调阈值。**缓解:** 先只对 5 个核心页面(首页/工作台/告警详情/患者详情/咨询)跑,逐步扩展。
|
||||
4. **Web 适老化业务价值需产品确认**:医疗管理后台多为医护使用,elder 模式优先级可能低于小程序适老化。**缓解:** 先做 motion/a11y token(医护通用受益),elder variant 待产品确认。
|
||||
5. **情境感知适老化涉及行为数据采集**:与 PP-12 合规通道缺失形成张力。**缓解:** 信号只在端内计算、不落库、不上传,隐私政策声明;保留手动开关且不静默改设置。
|
||||
6. **跨端契约抽象过早绑定错误 shape**:6 类语义组件契约若设计不当会绑定错误 API surface。**缓解:** 先落地 2 类(AlertCard + EmptyState)验证后再扩。
|
||||
7. **DTCG/Figma 双向 sync 多人协作冲突**:Figma 同时被多人改易冲突。**缓解:** 引入 lock 机制或约定"每周一次设计 sync 日"。
|
||||
8. **键盘快捷键与浏览器/输入法冲突**:需可配置。**缓解:** 参照 Linear/GitHub 键位规范,提供设置页自定义。
|
||||
|
||||
---
|
||||
|
||||
## 七、路线(Roadmap,对齐总览 Phase 0-3)
|
||||
|
||||
| 阶段 | 时间 | 本主题交付物 |
|
||||
|------|------|------------|
|
||||
| **Phase 0** | 0-2 周 | Quick Wins 1-3(PP-09 死链 + PP-06 角标 + haptic 接入);Web Vitals MVP |
|
||||
| **Phase 1** | 1-3 个月 | 举措 1 Phase 1(dashboardNavMap + CI 门禁);举措 2 Phase 1(DTCG 单源 + 生成器 + CI diff 检查);举措 3 Phase 1(AlertCard + EmptyState 契约);举措 4 Phase 1(多通道触达) |
|
||||
| **Phase 2** | 3-6 个月 | 举措 1 Phase 2(跨端 NavigationContract);举措 2 Phase 2(四态语义 token + Web 适老化 variant + motion/a11y token);举措 3 Phase 2(DTO 对齐);举措 5 Phase 2(键盘快捷键 + 撤销模式 + 批量操作) |
|
||||
| **Phase 3** | 6-12 个月 | 举措 2 Phase 3(Figma 双向 sync + 视觉回归 + WCAG 门禁);举措 3 Phase 3(6 类契约全覆盖);举措 5 Phase 3(情境感知适老化 + 情感文案) |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:证据核验清单(已逐项验证)
|
||||
|
||||
| 论断 | 证据 | 核验结果 |
|
||||
|------|------|---------|
|
||||
| Web 色值 #2563EB | `apps/web/src/index.css:12` `--erp-primary: #2563eb` | ✓ |
|
||||
| 小程序色值 #C4623A | `apps/miniprogram/src/styles/variables.scss:5` `$pri: #C4623A` | ✓ |
|
||||
| App.tsx 4 套硬编码 themeConfigs | `apps/web/src/App.tsx:153/175/194/218` colorPrimary | ✓ |
|
||||
| PP-09 死链 follow-ups | `AdminDashboard.tsx:51` navigate('/health/follow-ups') vs `routeConfig.ts:60` 实际 /health/follow-up-tasks | ✓ |
|
||||
| PP-09 死链 vital-signs | `AdminDashboard.tsx:69` navigate('/health/vital-signs')(routeConfig 无此路由) | ✓ |
|
||||
| 僵尸写法 value=0 | `AdminDashboard.tsx:88` `value={healthDataStats ? 0 : 0}` | ✓ |
|
||||
| PP-06 角标 index:2 | `useAlertPolling.ts:64` setTabBarBadge({index:2}) vs `app.config.ts:80` index 2=商城 | ✓ |
|
||||
| 告警应到 index 3 | `app.config.ts:81` index 3=助手(messages) | ✓ |
|
||||
| Web CACHE_TTL 5s | `apps/web/src/api/client.ts:11` `CACHE_TTL = 5000` | ✓ |
|
||||
| Web 预检 token 刷新 | `client.ts:63/74` isTokenExpiringSoon | ✓ |
|
||||
| 小程序 ResponseCache 60s | `apps/miniprogram/src/services/request.ts:3/66` | ✓ |
|
||||
| 小程序 ERROR_CODE_MAP | `request.ts:15/246` | ✓ |
|
||||
| Web 无 error_code 映射 | client.ts grep 无 ERROR_CODE_MAP | ✓ |
|
||||
| Web aria-live 仅 1 处 | `PluginDashboardPage.tsx:448` role="alert" | ✓ |
|
||||
| Web 无 web-vitals | grep web-vitals/onLCP/onCLS/onINP 全 0 命中 | ✓ |
|
||||
| Web 无 i18n | grep useTranslation/FormattedMessage 全 0 命中 | ✓ |
|
||||
| Web 无 elder-mode | grep elder-mode/elderMode 全 0 命中 | ✓ |
|
||||
| 无 packages 目录 | `ls packages` → NO packages dir | ✓ |
|
||||
|
||||
---
|
||||
|
||||
> 本主题与 00-INDEX.md 的 T4(僵尸 UI 清理)、PP-06(告警角标)、PP-09(Dashboard 死链)强关联,是这些上线前就绪项向"长期治理体制"的升格。
|
||||
125
docs/discussions/2026-06-25-analysis/05-compliance.md
Normal file
125
docs/discussions/2026-06-25-analysis/05-compliance.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 医疗合规与数据治理 — 主题综合
|
||||
|
||||
> 主持主题: 满足等保 2.0 / 数据安全法 / 个人信息保护法 / 病历管理规定,具备过认证能力,建立患者数据主权与可审计闭环。
|
||||
> 日期: 2026-06-25 | 视角: 医疗合规专家 + 数据安全专家 + 隐私保护官(DPO) 三方综合
|
||||
|
||||
## 1. 主题愿景
|
||||
|
||||
把 HMS 从「修漏洞式合规」升级为「可举证的持续合规闭环」: 以**数据分类分级为底座**、**患者数据主权为核心**、**外部可验证的完整性举证为抗辩武器**, 让系统在等保三级测评、个保法年度审计、外资/跨境医疗租户采购评估三类场景下, 都能输出机器可验证的合规证据链, 而非依赖文档承诺或人工 review。
|
||||
|
||||
三位专家的核心共识: 当前 PII 加密(KEK+DEK+盲索引)、consent 实体、审计哈希链是难得的好底子, 但存在三类「结构性断裂」——加密不对称(name 明文漏网)、自动化只写不验(verify_hash_chain 无调用方 / DEK 版本硬编码为 1)、无数据主体权利履行通道(导出/删除/留存零实现)。这些不是上线前就绪度问题, 而是 6-12 个月合规演进必须补齐的能力。
|
||||
|
||||
## 2. 代码核实的关键事实(alreadyKnown 标注)
|
||||
|
||||
| # | 事实 | 证据 | 历史 | 增量判定 |
|
||||
|---|------|------|------|----------|
|
||||
| F1 | patient.name 明文存储 | `entity/patient.rs:12` `pub name: String`; `service/patient_service/crud.rs:174` `name: Set(req.name)`(同函数 id_number:178 走 pii::encrypt) | 00-INDEX PP-12 列了「name 加密 + name_hash」清单项 | 已识别但未深挖(对称加密缺失) |
|
||||
| F2 | consent 医护角色全跳过 | `handler/consent_check.rs:9` BYPASS_ROLES=[admin,doctor,nurse,health_manager]; `:20` 命中即 return Ok | 无任何历史讨论提及 | **新发现** |
|
||||
| F3 | verify_hash_chain 零调用 | `audit_service.rs:168` 定义, 全仓 grep 无调用方 | 无(PP-01/PP-05 同构「实现完整接线缺失」) | **新发现** |
|
||||
| F4 | DEK key_version 硬编码 Some(1) | 7 处 `key_version: Set(Some(1))` 散布 patient/lab_report/diagnosis/doctor/follow_up/consultation service | 无 | **新发现**(轮换管线存在但消费侧硬编码) |
|
||||
| F5 | erp-ai + erp-core 的 dev_default 无 cfg 防护 | `erp-core/src/crypto/mod.rs:37` 和 `erp-health/src/crypto.rs:48` 均无 `#[cfg(any(debug_assertions, test))]`; erp-ai 的 `config_resolver.rs:317` + `config_handler.rs:80` 直接调 `dev_default().kek()` | wiki 称「P4 dev_default 添加 cfg(debug_assertions)」仅 health/server 侧, erp-core/erp-ai 漏网 | **新发现**(推翻 wiki「已修复」) |
|
||||
| F6 | consent_method 无签名验证 | `entity/consent.rs:21` `pub consent_method: Option<String>` 自由文本 | 无 | **新发现** |
|
||||
| F7 | 无数据导出/留存管线 | `grep data_export\|retention_policy\|right_to_be_forgotten` 0 命中(仅菜单文案) | 00-INDEX PP-12 列了清单 | 已识别但未设计 |
|
||||
| F8 | 脱敏硬编码 + 出口处调用 | `masking.rs` 硬编码 mask_id_number 前3后4, 仅 helper.rs 出口调用 | 无 | **新发现**(无策略表, 无 ABAC) |
|
||||
|
||||
## 3. 战略举措(归并 14 条提案为 4 项)
|
||||
|
||||
### I-1 PII 加密链路打通与 DEK 轮换闭环(合规地基)
|
||||
|
||||
**rationale**: F1+F4+F5 三处加密不对称, 让「PII 加密企业级」的宣称在测评员面前一戳即破——name 是唯一明文敏感 PII, DEK 版本恒为 1 让轮换按钮变成「点了就损坏历史数据」的潜伏地雷, erp-ai 全链路 dev KEK 更是「医疗化验单对任意代码读者明文」的硬伤。
|
||||
|
||||
**phases**:
|
||||
1. **dev_default 编译期硬隔离** — 给 `erp-core/crypto/mod.rs:37` + `erp-health/crypto.rs:48` 的 dev_default 加 `#[cfg(any(debug_assertions, test))]`, release 构建编译失败; erp-ai 的 2 处调用改为从 AiState 注入 `Arc<PiiCrypto>`; 一次性脚本把 settings 表中 dev KEK 加密的 Provider key 用旧 KEK 解密 + 新 KEK 重加密。
|
||||
2. **DEK 版本回溯解密** — 新增 `current_dek_for_tenant(tenant_id) -> (dek, key_version)`; 7 处 `Set(Some(1))` 改为真实 version; decrypt_field 按行 key_version 反查 DEK; LRU 缓存。
|
||||
3. **name 加密 + name_hash 盲索引** — m000170 加 name_encrypted + name_hash; 复用 pii::encrypt; expand→migrate→contract 三步在线迁移; FHIR name 查询降级为 hash 精确 + 提示; 提供 feature flag 让国资体检中心可关掉加密满足特殊检索需求。
|
||||
4. **回溯测试** — 轮换后 v2 写、v1 读、re-encrypt 迁移子命令全测。
|
||||
|
||||
**effortEstimate**: 5-7 人日(P1=1d / P2=2d / P3=2-3d / P4=1d)
|
||||
**expectedImpact**: 消除唯一明文 PII 漏点 + 关闭「轮换即损坏」潜伏故障 + 关闭 AI 模块明文解密能力泄漏; 等保三级「数据机密性」从「宣称」升级为「可验证」。
|
||||
**kpis**: [release 构建 0 处 dev_default 调用, 全仓 Sensitive-PII 列加密率 100%, DEK 轮换后历史数据可解密率 100%, PII 日志泄漏点 0]
|
||||
**dependencies**: [P-3 数据分类分级引擎(确定哪些字段属 Sensitive), backfill 停机窗口或在线双写]
|
||||
|
||||
### I-2 数据分类分级引擎 + 字段级 ABAC 访问控制(可举证底座)
|
||||
|
||||
**rationale**: F8 显示脱敏是「硬编码 + 出口处一刀切」——医生看诊拿不到完整身份证号、运营拿到的密文要前端解。等保三级「分级保护 + 最小授权」要求按字段敏感度差异化控制, 且必须后端单点决策(前端硬编码脱敏在合规审计中判「未实施访问控制」)。
|
||||
|
||||
**phases**:
|
||||
1. **分类分级元数据** — erp-core 新增 SensitivityLevel(Public/Internal/Confidential/Restricted/Sensitive-PII/Sensitive-Medical) + DataCategory 枚举; m000171 data_dictionary 表 或 Entity attribute 标注; 先覆盖 health+ai ~30 核心实体。
|
||||
2. **CI 不变量测试** — `#[test]` 断言所有标 Sensitive-* 的列必须满足三项之一(列名 _encrypted / 同名 _hash 盲索引 / RLS 强策略); 把「敏感字段未加密」变成编译/CI 失败。
|
||||
3. **ABAC for PII** — m000172 pii_access_policy 表(role × field × reveal/mask/hash-only × purpose); handler 序列化前查策略(缓存 Redis, key=tenant+role, TTL 5min); 复用 mask_phone/mask_id_number + reveal_full; 每次reveal 写 audit_log 含 purpose。
|
||||
4. **自动生成分类文档** — 输出 data_classification.md 作为测评呈堂材料; 前端从 `_pii_meta` 字段读呈现策略。
|
||||
|
||||
**effortEstimate**: 8-10 人日(分类标准需法务/医疗顾问参与)
|
||||
**expectedImpact**: 把「分级保护」从口号变为机器可验证不变量; 脱敏从「上下文无关」升级为「医生 reveal / 运营 mask / 统计 hash-only」的合理工程; 产出测评呈堂材料。
|
||||
**kpis**: [Sensitive-PII/Sensitive-Medical 字段 100% 标注, CI 分级不变量测试通过率 100%, 脱敏策略后端单点覆盖率 100%, 审计 purpose 字段填充率 100%]
|
||||
**dependencies**: [I-1 P3 name 加密完成(确定最终敏感字段集), 法务/医疗顾问确认基因 vs 一般医疗诊断级别差异]
|
||||
|
||||
### I-3 数据主体权利履行闭环(个保法 §45/§47 + 病历留存)
|
||||
|
||||
**rationale**: F7 显示导出/删除/留存零实现。个保法 §45(可携带权)/§47(删除权)是硬性义务, 外资/跨境医疗租户和等保测评会专门查这两个端点。医疗行业特殊性: 删除权与病历法定留存(门诊 15 年 / 住院 30 年)冲突, 必须分级处理而非一刀切。
|
||||
|
||||
**phases**:
|
||||
1. **导出 API** — `GET /api/v1/health/patients/me/export` 聚合 patient+health_record+vital_signs+lab_report+consultation+device_readings+follow_up+appointment+consent+points; 输出 FHIR Bundle(JSON)+CSV 双格式; 异步任务复用 ai_analysis_queue; 产物 OSS 签名 URL 7 天 + 二次认证 + IP 绑定 + 下载次数限制 + 水印。
|
||||
2. **分级删除/匿名化** — `DELETE /api/v1/health/patients/me` 分级: 非法定数据(咨询历史/积分明细/AI 分析结果)立即匿名化或硬删除; 法定病历走 `retention_until` 字段 + 软删除; 引入 **anonymize 中间态**(去标识化保留统计价值)作为「删除权 vs 法定留存」的法律/技术平衡点。
|
||||
3. **留存策略引擎** — m000173 retention_policy 表(entity/field/retain_days/action{delete|anonymize|archive}|scope); 接入 erp-server/tasks.rs 现有 spawn; 每日扫描 deleted_at+retain_days<now() 执行; 医疗 15 年 / 营销 5 年 / AI 日志 180 天 / 审计永久; 支持**租户级覆盖**(体检中心 vs 健康管理咨询差异化)。
|
||||
4. **DPO 仪表盘** — 患者门户「我的数据权利」(小程序自助申请 + Web 医护代申请两通道); 操作写 audit_log(patient.data_exported / patient.erasure_requested); dry-run + 二级审批工作流(复用 erp-workflow); 宽限期 to_purge_at 7 天。
|
||||
|
||||
**effortEstimate**: 12-15 人日(P1=3d / P2=4d / P3=3d / P4=2-3d)
|
||||
**expectedImpact**: 履行个保法 §45/§47 法定义务; 拿下外资/跨境医疗租户采购评估; anonymize 中间态兼顾合规与统计科研价值; 患者数据主权成为产品差异化。
|
||||
**kpis**: [导出端点存在且通过 E2E, 删除权响应时效 ≤30 天(法定), 留存策略覆盖率 100% 核心实体, 误删事故 0(dry-run + 审批)]
|
||||
**dependencies**: [I-2 分类分级(确定哪些字段属病历法定留存), 法务明确病历 vs 非病历字段清单, I-1 KEK 真实化(导出解密正确)]
|
||||
|
||||
### I-4 完整性举证与读操作审计(抗篡改 + 内部越权检测)
|
||||
|
||||
**rationale**: F3 显示 verify_hash_chain 零调用——审计哈希链「装了摄像头但不回放」, DBA 可整批重算 hash 篡改历史而不被发现, 等保测评员会质疑「自证清白」。F2 显示医护全跳过 consent——个保法对敏感信息处理无职业豁免, 只有「法定义务/紧急情况」豁免且必须显式记录。当前 AuditLog 只记写操作, 抓不到「内部人员越权查看」这一隐私事件主根因。
|
||||
|
||||
**phases**:
|
||||
1. **哈希链实时验证** — 新增后台任务 `start_audit_chain_verifier(interval=3604s)` 调用 verify_hash_chain; 断裂即写 alerts + critical tracing::error + Prometheus `audit_chain_broken_count`; 关键操作(登录/权限变更/数据导出/批量删除)断裂 fail-closed 阻断同租户后续写; 首次运行做基线扫描避免上线即告警刷屏。
|
||||
2. **外部锚点(WORM)** — 每日 ETL 取 audit_logs 当日最后 record_hash 写入 WORM 存储(腾讯云 COS 版本锁定桶 / 本地 append-only 文件 fallback); 提供 `verify_audit_chain(start,end)` 从外部锚点重放校验; 公开锚点桶地址。
|
||||
3. **consent 法定豁免通道** — consent_check.rs 移除医护硬跳过, 改为「访问已撤回同意数据时强制填 access_purpose(急诊/法定义务/科研)」落审计; 提供急诊一键声明快速通道 + 事后审计(避免拖慢急诊流程)。
|
||||
4. **读操作审计独立层** — 新增 `data_access_log` 表(独立于 audit_log, 按月分区同 device_readings 模式); 字段 viewer_user_id/role/patient_id/resource_table/access_purpose/consent_reference; trait DataAccessAuditor 注入所有读 PII handler; DPO 仪表盘异常访问检测(1h 查 200 患者 = 爬取告警); 异步批量写入控制性能。
|
||||
|
||||
**effortEstimate**: 10-12 人日(P1=2d / P2=2-3d / P3=2-3d / P4=4d)
|
||||
**expectedImpact**: 审计从「自证清白」升级为「外部不可变锚点可举证」; 补齐等保三级明确要求而当前缺失的「读操作可审计」; 关闭医护 consent 特权黑洞。
|
||||
**kpis**: [verify_hash_chain 接线后基线扫描 0 断裂, 读操作审计覆盖核心读 handler 100%, 医护访问已撤回 consent 数据记录率 100%, 异常访问告警 MTTD ≤1h]
|
||||
**dependencies**: [PP-04 Alertmanager 通知出口(告警可达), WORM 存储基础设施(私有化客户需本地 fallback), I-2 ABAC purpose 字段复用]
|
||||
|
||||
## 4. 速赢(1-2 周可落地)
|
||||
|
||||
1. **dev_default 编译期硬隔离 + erp-ai KEK 注入**(I-1 P1, 1-2 人日) — 给 `erp-core/crypto/mod.rs:37` + `erp-health/crypto.rs:48` 加 `#[cfg(any(debug_assertions, test))]`; erp-ai 2 处调用改 AiState 注入。**收益**: 关闭「任意代码读者可解密医疗化验单」的硬伤, 推翻 wiki「已修复」误记, effort/impact 比最高。**风险**: 可能暴露其他漏网 dev_default 调用点, 需全仓扫描。
|
||||
2. **审计哈希链接线 + 基线扫描**(I-4 P1, 1-2 人日) — `verify_hash_chain` 函数已实现, 只需 spawn 任务调用 + 首次基线扫描。**收益**: 补齐「完整性证明」能力, effort 极低(函数已存在), 推翻「装了摄像头但不回放」的纸老虎状态。
|
||||
3. **PII 日志 PiiRedactLayer**(跨提案, 1 人日) — tracing-subscriber layer 统一拦截 name/phone/id_number/address/email 字段 + 写 test-log capturing 回归测试。**收益**: 根治 V2 审计修了仍残留的 PII 日志泄漏点, 从「逐文件删字段」升级为「框架级不变量」。
|
||||
|
||||
## 5. 主题级风险
|
||||
|
||||
1. **加密迁移不可逆** — name 加密 + DEK 轮换 + backfill 期间双列并存, 任一步骤失败可能导致历史数据永久不可解密。**缓解**: expand→migrate→contract 三步在线迁移 + dry-run + 低峰期 + 完整备份。
|
||||
2. **删除权 vs 病历法定留存法律冲突** — 个保法 §47 与《医疗机构病历管理规定》直接对立, 误删病历触发反向违规。**缓解**: anonymize 中间态 + 法务明确分级清单 + 二级审批 + 宽限期。
|
||||
3. **读操作审计性能与存储成本** — 每次读 PII 都写日志, 每月可能数 GB, 拖慢主路径。**缓解**: 异步批量写入 + Redis 缓冲 + 按月分区 + 按敏感度采样。
|
||||
4. **WORM 存储私有化适配** — 单机部署的私有化客户无对象存储。**缓解**: 本地 append-only 文件 fallback + 每周导出。
|
||||
5. **FHIR 互操作让步** — name 加密后 FHIR Patient.name 模糊查询语义弱化, 外部 FHIR 客户体验下降。**缓解**: 降级为 hash 精确 + 提示, feature flag 让国资体检中心可关闭加密。
|
||||
6. **purpose-scoped consent 误伤 AI** — AI 模块复用 patient 数据, purpose 未覆盖会误伤现有功能。**缓解**: 默认 purpose=treatment 兼容 + ai_training/cross_border_share 作为可选授权。
|
||||
|
||||
## 6. 专家分歧调和(最终取舍)
|
||||
|
||||
| 分歧 | 一方立场 | 另一方立场 | 最终取舍 |
|
||||
|------|---------|-----------|----------|
|
||||
| **name 是否全量加密** | 后端/性能: FHIR 互操作 + decrypt-on-read 性能损耗 | 合规/DPO: 红线优先, 准标识符重识别风险 | **采纳加密 + feature flag**——默认加密满足合规, 国资体检中心可关闭; DTO 批量 decrypt + 缓存缓解性能; FHIR 降级 hash 精确。合规红线不让步, 但给特殊场景留逃生口。 |
|
||||
| **数据导出是否 over-engineering** | 产品: 体检中心不会真用 | 合规: 法定义务 + 采购评估硬指标 | **采纳**——个保法 §45 是法定义务不是产品功能, 缺即判不合规; anonymize 中间态对统计科研有附加价值, 非纯成本。 |
|
||||
| **审计外部锚点是否过度** | 安全: 内部哈希链已足够 | 合规: 内部哈希链自证清白无证明力 | **采纳外部锚点 + 本地 fallback**——等保三级「完整性」核心是「防内部篡改可举证」, 但私有化客户用本地 append-only 文件, 不强制对象存储。 |
|
||||
| **医护跳过 consent 是否便利** | 业务: 急诊流程不能拖慢 | 合规/DPO: 无职业豁免, 须显式记录法定豁免理由 | **采纳法定豁免通道**——移除硬跳过, 改为急诊一键声明 + access_purpose 落审计; 平衡急诊时效与合规举证。 |
|
||||
| **脱敏前端还是后端** | 前端: 按角色判断 | DPO/数据安全: 后端单点决策 + 审计 | **采纳后端 ABAC**——前端硬编码脱敏在合规审计中判「未实施访问控制」, 必须后端单点 + `_pii_meta` 元数据驱动前端呈现。 |
|
||||
| **DEK 轮换上线前还是上线后** | DevOps: 先上线再轮换 | 数据安全: 轮换管线已损坏, 上线即定时炸弹 | **采纳上线前修复**——这是「生产即损坏」潜伏故障, 管理员误点轮换按钮即历史数据不可解密, 列为上线前 blocker。 |
|
||||
| **数据销毁永久保留 vs 分级** | AI/产品: 永久保留支撑训练 | DPO/数据安全: 违反数据最小化 + 法定删除权 | **采纳分级留存**——医疗按法定期(15/30 年)、营销短期(5 年)、AI 日志(180 天), anonymize 兼顾科研与合规。 |
|
||||
|
||||
## 7. 路线(6-12 个月)
|
||||
|
||||
| 阶段 | 时间窗 | 举措 | 里程碑 |
|
||||
|------|--------|------|--------|
|
||||
| **T0 上线前 blocker** | 立即-2 周 | I-1 P1(dev_default 隔离)+ I-4 P1(哈希链接线)+ PiiRedactLayer | release 构建 0 dev_default / 哈希链基线扫描通过 / PII 日志 0 泄漏 |
|
||||
| **T1 加密链路打通** | 1-2 月 | I-1 完整(name 加密 + DEK 轮换回溯) | Sensitive-PII 加密率 100% / 轮换可用 |
|
||||
| **T2 分类分级 + ABAC** | 2-4 月 | I-2 完整(元数据 + CI 不变量 + ABAC 策略) | 测评呈堂材料输出 / 脱敏后端单点 |
|
||||
| **T3 数据主体权利** | 3-6 月 | I-3 完整(导出 + 分级删除 + 留存引擎) | 个保法 §45/§47 端点可演示 / DPO 仪表盘 |
|
||||
| **T4 完整性 + 读审计** | 4-8 月 | I-4 完整(外部锚点 + consent 法定豁免 + 读审计) | 等保三级测评可过 / 读操作异常检测上线 |
|
||||
| **T5 跨境/DPIA 网关** | 6-12 月 | data_transfer_gateway + AI 调用数据最小化 | 外资/跨境租户合规通道预留 |
|
||||
|
||||
> 总投入估算: T0-T4 约 35-44 人日(渐进式, 不阻塞 V1 上线)。T5 为演进预留, 当前无跨境租户, 优先级低但架构位要留。
|
||||
217
docs/discussions/2026-06-25-analysis/06-saas-growth.md
Normal file
217
docs/discussions/2026-06-25-analysis/06-saas-growth.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 主题 06 — 商业增长与 SaaS 规模化
|
||||
|
||||
> 日期: 2026-06-25 | 分支: feat/media-library-banner | 阶段: V1 上线后 6-12 个月演进
|
||||
> 专家: SaaS 产品经理 / 商业化架构师 / 增长专家
|
||||
> 证据基准: 代码核实(非臆测),所有论断标注文件路径:行号
|
||||
|
||||
---
|
||||
|
||||
## 一、主题愿景
|
||||
|
||||
把 HMS 从「单租户项目交付」转变为「按价值计费、配置化开通、数据飞轮驱动续约」的可规模化 SaaS,核心抓手是把现有但分散的半成品(`ai_usage`/`ai_tenant_config` 配额引擎、`points_*` 6 表、`EventBus`+Outbox、AnalysisQueue)缝合为三根商业主线:**统一计量计费中枢**(每多一个租户的实施成本从 3-5 人天降到小时级)、**配置化交付蓝图**(场景模板化,让交付从写代码变成配 JSON)、**主动关怀+行为经济飞轮**(把 AI 队列和积分系统从被动记录变成无人值守的留存与续约引擎)。SaaS 定价模型一旦随首批医疗客户固化(合同周期长、改价难),后续调整成本极高,必须在签第 2-5 个客户前确立计量与分层的数据基础,否则会陷入「每个客户一份定制合同」的项目制泥潭——这正是从交付到 SaaS 失败的典型路径。
|
||||
|
||||
**关键判断(与决策简报 00-INDEX.md 对齐)**:本主题不阻塞 V1 上线(Phase 0 拆炸弹优先),但所有举措建立在 PP-01 死信接线 + PP-05 队列消费者 + PP-07 RLS FORCE + PP-02 分区自愈这四个 CRITICAL 前置修复完成之后。商业化是上线后 6-12 个月的事,但计量埋点与 entitlement 抽象必须提前到 Phase 1,否则后期改造代价数倍。
|
||||
|
||||
---
|
||||
|
||||
## 二、专家提案摘要与核实
|
||||
|
||||
### 已核实的事实(alreadyKnown=true,作为商业化基础设施的真实存在)
|
||||
|
||||
| 资产 | 位置 | 现状(核实) | 商业化含义 |
|
||||
|------|------|-------------|-----------|
|
||||
| AI 配额引擎 | `crates/erp-ai/src/service/quota.rs:7-134` | 仅 token 单维度,仅「超限拒绝」(L41-49),无账单出口 | 可泛化为多维度计量 |
|
||||
| AI 日聚合 | `crates/erp-ai/src/service/usage.rs:110-158` `aggregate_daily` | 按分析类型聚合到 `ai_usage_daily`,仅 AI | rollup 模式可复用到全模块 |
|
||||
| 积分 6 表 + CAS | `points_service/account.rs:82` `earn_points` + `event.rs` | 4 个 earn 触发点(checkin/follow_up/care_plan/offline_event),FIFO 消费+CAS 防超卖 | 积分商城已 78% 核销基础设施,差人民币 leg |
|
||||
| 积分过期 | `event.rs:772-875` `expire_points` | 已实现 12 个月过期清理 + `POINTS_EXPIRED` 事件 | decay 机制已存在,增长专家「需加通胀设计」可调参数 |
|
||||
| `tenant.settings` JSON | `migration/m20260410_000001:28` | 空可空 JSON 列 | branding_json 零迁移可承载白标 |
|
||||
| 小程序主题 token | `apps/miniprogram/src/styles/tokens.scss` + `token-values.ts` | 11 级字号 + 结构 token + `.doctor-mode`/`.elder-mode` 覆盖 | 白标皮肤沉没成本已就绪,边际成本极低 |
|
||||
| EventBus + Outbox | `crates/erp-core/src/events.rs` | 31 health 事件/82 发布点/15 消费者 | 计量 consumer 可挂载,零新增基础设施 |
|
||||
| `retry_dead_letters` | `events.rs:382` | 函数存在但 `tasks.rs` 未注册调度(PP-01) | 计量 consumer 必须先于死信修复,否则计量丢失无察觉 |
|
||||
|
||||
### 增量缺口(alreadyKnown=false,本主题真正要建的)
|
||||
|
||||
- `claim_next`(`analysis_queue.rs:92`)**无生产消费方**——`auto_analysis.rs:108` 只 enqueue(每 24h),从不消费队列;PP-05 确认。
|
||||
- 无 `tenant_usage_ledger`/`metering_daily`/`tenant_quota`/`tenant_plan`/`tenant_blueprint`/`tenant_entitlements` 表——全仓 grep 零命中。
|
||||
- 无 `AppError::PaymentRequired`/402 映射——`error.rs:17-39` 仅 8 变体(NotFound/Validation/Unauthorized/Forbidden/Conflict/VersionConflict/RateLimit/Internal)。
|
||||
- 无 `EntitlementChecker` trait / `quota_guard` 中间件 / `entitlement_middleware`。
|
||||
- 无 `start_usage_rollup` / `start_auto_analysis_worker` 调度(`tasks.rs` 只有 event_cleanup + pool_metrics)。
|
||||
|
||||
---
|
||||
|
||||
## 三、战略举措(5 项,归并 3 专家 14 提案)
|
||||
|
||||
### 举措 1 — 统一计量计费中枢(Metering Hub)
|
||||
|
||||
**归并**: SaaS-PMP1 + 商业架构M1 + M2(配额即产品)。把 `ai_usage`/`ai_tenant_config` 与 `points_*` 升级为跨模块事件驱动计量 + 套餐配额矩阵。
|
||||
|
||||
**rationale**: 现有计量是两座孤岛——AI 只统计 token(`quota.rs:41`),积分商城无人民币核销闭环。不统一计量就无法对 AI/告警/咨询/FHIR 调用定价,AI 越主动成本越不可控(商业反模式)。
|
||||
|
||||
**phases**:
|
||||
1. **P1(埋点,1-2w)**: 新增 `metering_daily(tenant_id, metric_key, day, qty, dim_hash, unique 索引)` 表,用 `ON CONFLICT DO UPDATE` 原子累加;在 `usage.rs`、`points_service/event.rs`、`device_reading_service`、`consultation_service`、fhir handler 挂 `Meter::record()` 写入。**独立连接池**避免 PP-08 RLS 串扰。
|
||||
2. **P2(配额中间件,2-3w)**: 新增 `AppError::PaymentRequired` 变体(402);抽象 `quota_guard` 中间件(类似 `require_permission`),计费端点声明 `dimension`;预置 3 套餐 seed(基础/专业/旗舰)到 `tenant_plan` + `tenant_quota` 表。
|
||||
3. **P3(账单导出,1w)**: `GET /api/v1/admin/tenants/{id}/usage` + `/invoice?period=YYYY-MM` 返回 JSON+CSV。不接 Stripe(医疗 to B 公对公转账为主)。
|
||||
|
||||
**effortEstimate**: 4-6 周(1-2 后端 + 财务定义价格表后置)
|
||||
|
||||
**expectedImpact**: 打开按价值付费收入天花板;每个 AI 分析/告警/咨询/上传可计量可计费;为 P2 套餐分层提供数据基础。从「内部成本控制」反转为「商业化基础设施」。
|
||||
|
||||
**kpis**: 计量覆盖率(计费事件/总事件);月度账单生成准时率;配额误拦截事故数(目标 0);首 3 客户计量对账差异率(<1%)。
|
||||
|
||||
**dependencies**: PP-01 死信接线(计量 consumer 必须有重试兜底);PP-08 RLS 修复(独立池);财务/销售定义价格表(非技术决策);PP-02 分区自愈(device_active_count 计量依赖)。
|
||||
|
||||
**关键取舍**: 不引入 Stripe/Lago 等通用计费引擎(医疗账单维度强绑定业务事件,自建更贴合,省 2-3 周集成)。配额硬上限(hard_cap 402)必须为 `critical_alert`/`article` 推送设白名单,否则会阻断医疗业务——这是真实产品权衡,不是 bug。
|
||||
|
||||
---
|
||||
|
||||
### 举措 2 — 配置化交付蓝图(Tenant Blueprint + Onboarding)
|
||||
|
||||
**归并**: SaaS-PMP3 + 商业架构M4 + 增长专家配置化场景。把新租户开通从「跑迁移+手工 SQL」降到「点按钮」。
|
||||
|
||||
**rationale**: 现状菜单/权限/字典/告警阈值/随访模板/积分规则全靠 `m0000xx_seed_*.rs` 一次性写入,新租户要么继承默认要么手工 SQL——这是「项目交付而非 SaaS」的根因。体检中心连锁(20-50 门店)是 HMS 核心增长客群,交付周期是规模化第一瓶颈。
|
||||
|
||||
**phases**:
|
||||
1. **P1(蓝图表,1-2w)**: 新增 `tenant_blueprint(id, name, module_set, quota_plan_id, seed_data JSON, branding_json, schema_version)`。把现有 seed 内容重构为按行业分类的 preset 数据包(`general_hospital`/`dialysis_center`/`checkup_clinic`)存 `config/presets/*.toml`(数据非迁移,可版本化)。
|
||||
2. **P2(provision 端点,2-3w)**: `POST /api/v1/admin/tenants/{id}/provision`,事务化建 tenant→角色/权限/菜单→points_account→触发 `tenant.provisioned` 事件(补齐 §3.4「每个事件有消费者」铁律)。配套 CLI `erp-server tenant create --blueprint=standard`(顺便补 PP-11 subcommand 缺失)。
|
||||
3. **P3(导出标杆,1w)**: 「导出当前租户配置为 preset」按钮,白名单字段 + PII 过滤(与 PP-12 同源),把标杆客户最佳实践沉淀为可复用模板。
|
||||
|
||||
**effortEstimate**: 4-5 周
|
||||
|
||||
**expectedImpact**: 实施周期从 3-5 人天降到小时级;第 N 个客户复用第 1 个客户的最佳实践配置;支撑体检连锁批量获客;让销售/实施可在签单当天开通试用(医疗采购「先试后买」决策周期)。
|
||||
|
||||
**kpis**: 新租户开通耗时(目标 <30 分钟,含验证);preset 复用率(新租户用 preset 而非手工 SQL 的比例);试点客户首月留存。
|
||||
|
||||
**dependencies**: PP-11 CD pipeline + 迁移独立子命令;preset schema_version + 增量 patch 机制(版本兼容);admin 审批流/邀请码(防刷租户消耗配额)。
|
||||
|
||||
---
|
||||
|
||||
### 举措 3 — 主动关怀 + 行为经济飞轮
|
||||
|
||||
**归并**: 增长专家 G1(AI 队列接通)+ G2(积分行为经济)+ G4(转介绍)。把半成品 AI 队列和积分系统从被动记录变成留存引擎。
|
||||
|
||||
**rationale**: `claim_next` 存在但无消费方(`analysis_queue.rs:92`),客户基于「主动关怀」付费但队列是死存储。积分仅 3-4 触发点无消费压力,是债务不是货币。医疗行业 CAC 高,但现有患者家属是天然高质量线索池。
|
||||
|
||||
**phases**:
|
||||
1. **P1(队列消费者 MVP,2w)**: `tasks.rs` 新增 `start_auto_analysis_worker`:tokio::spawn 循环调 `claim_next`,按 tenant 并发拉取,失败走 `retry_dead_letters`(同时修 PP-01)。worker 数与 `ai_tenant_config` 配额做令牌桶联动(避免单租户耗尽 Provider)。
|
||||
2. **P2(三种触达,2-3w)**: 高风险患者→医护 action_inbox「建议介入」项;化验上传→患者 AI 解读 push;每日巡护→依从性下降患者关怀消息(复用 `article_service.rs:228` 事件消费者模式)。每条触达带 `source=auto_analysis` + outcome 跟踪字段,回流增长看板。
|
||||
3. **P3(积分行为经济,1-2w)**: 扩充 earn 订阅(BLE 上传 +X、连续达标 +bonus、健康评估 +Y、咨询评价 +Z,复用 DomainEvent 无需新表);积分消耗侧新增「健康权益兑换」(优先挂号/远程咨询时长/家属账号)而非仅商城商品;`points_rule` 加 `decay_policy` 调参(过期已实现,需运营定义通胀曲线)。
|
||||
4. **P4(转介绍闭环,2w)**: `patient` 加 `referred_by_patient_id` 自引用 + `referral_code`;小程序「我的」生成小程序码;反作弊(同手机号/身份证仅一次被介绍,复用 `phone_hash` 盲索引)+ 首次就诊才发放(防刷);运营看板「转介绍漏斗」。
|
||||
|
||||
**effortEstimate**: 7-9 周(可分 P1→P4 串行,先有流水线再谈能力升级)
|
||||
|
||||
**expectedImpact**: 把「已付费但空转」的 AI 能力变成转介绍/续约引擎;患者依从性驱动数据密度;医疗转介绍产品化(合规边界内,奖励建档/健康行为而非就诊消费)。
|
||||
|
||||
**kpis**: AI 队列消费吞吐(pending 积压 <阈值);主动触达→就诊转化率;积分兑换深度(人均月兑换次数);转介绍建档数→就诊数漏斗;Top KOC 识别覆盖。
|
||||
|
||||
**dependencies**: PP-01 死信重试;PP-05 队列消费者(即本举措 P1);AI Provider 配额联动(`chat_handler` fallback chain);医务审核行为定义(防「诱导过度检查」合规);`consent` 拦截器(家属数据共享授权链路,已有)。
|
||||
|
||||
**关键取舍**: 增长专家主张 AI 队列接通优先级高于 PP-01/PP-02 等基础设施修复。调和:同一个 worker 同时承载 AI 消费和死信重试(都用 retry 机制不冲突),但顺序上 PP-01 仍是 Phase 0 阻塞项(计量 consumer 需要重试兜底,否则触达丢失无人察觉)。
|
||||
|
||||
---
|
||||
|
||||
### 举措 4 — 套餐分层与价值证明(Feature Gating + Health Score)
|
||||
|
||||
**归并**: SaaS-PMP2(Feature Gating)+ PMP4(ROI 仪表盘)+ 增长专家 G3(租户健康度看板)。把计量数据变成续约武器。
|
||||
|
||||
**rationale**: 常规 SaaS 只给客户看「使用量」;本举措做双视角——客户看自己 ROI(续约理由),平台看客户健康度(流失预警)。且不新搭数据仓库,全用现有 `stats_service` + `usage_ledger` 在线计算(刻意避免 BI 工具运维负担,匹配 DevOps 4.2 短板现实)。
|
||||
|
||||
**phases**:
|
||||
1. **P1(EntitlementChecker,2w)**: `crates/erp-core` 定义 `EntitlementChecker` trait,各 handler 入口(紧邻 `require_permission`)调 `require_feature("health.ai_copilot")`;集中 `entitlement_middleware` 按 route→feature 映射表统一拦截(防漏检=免费用户白嫖付费功能)。区分边界:permission 管「能不能做」(安全),entitlement 管「付费没付费」(商业)。
|
||||
2. **P2(套餐定义,1w)**: 预置 3 套餐存 `config/entitlements.toml`(非迁移,便于调整);前端 `apps/web` 新增「套餐管理」页给 super-admin;小程序按 entitlements 动态显示/隐藏 AI 助手、深度报告入口。
|
||||
3. **P3(健康度+ROI,2-3w)**: `stats_service` 新增 `tenant_health_score`(五维度:活跃患者占比、AI 触达数、告警闭环率、随访完成率、医护日活);`GET /api/v1/admin/tenants/{id}/health-score` 给 super-admin 流失预警;每租户 AdminDashboard 新增「我的 ROI」卡片。用现有 `stats_dto` + 4 Dashboard 组件基础设施。
|
||||
|
||||
**effortEstimate**: 5-7 周
|
||||
|
||||
**expectedImpact**: 防止免费能力裸奔(entitlement 漏检=收入损失);客户续约有量化依据;平台 NRR/流失预警从感觉变成可预警指标。
|
||||
|
||||
**kpis**: entitlement 检查覆盖率(计费端点/总端点);流失预警准确率(回测校准);续约率提升;客户 ROI 卡片使用率。
|
||||
|
||||
**dependencies**: PP-01 死信重试(「告警闭环率」指标依赖告警链路完整);运营/客户成功定义健康度权重(非技术拍板);跨租户聚合查询性能(物化视图或离线 rollup,与举措 1 共用调度)。
|
||||
|
||||
---
|
||||
|
||||
### 举措 5 — 生态扩展预留(白标皮肤 + 开放 API)
|
||||
|
||||
**归并**: SaaS-PMP5。为 6-12 个月后渠道分销(区域代理、体检连锁白标)铺路,**低成本预留**而非立即变现。
|
||||
|
||||
**rationale**: 小程序已投入做完整 CSS 变量主题体系(`tokens.scss` + `token-values.ts`,原为长者/医生模式服务),稍作扩展就是白标基础设施,把已沉没成本变成未来收入入口。
|
||||
|
||||
**phases**:
|
||||
1. **P1(白标皮肤,1-2w,与安全解耦可并行先做)**: 抽象 `tenant.branding_json`(logo_url, primary_color, app_name, login_bg_url)存 `tenant.settings`(零新表);Web 侧 Ant Design ConfigProvider runtime 切 theme token;小程序已有 `var(--tk-*)` 读取后动态注入 CSS 变量。
|
||||
2. **P2(开放 API,3-4w,严格 gate 在安全修复后)**: 现有 14 FHIR 端点旁新增 `GET /api/v1/open/patients` 等;`tenant_api_keys` 表(scope 字段控制可访问资源);速率限制复用 rate_limit 中间件但独立配额;API 调用写入举措 1 的 `metering_daily`(为「API 调用收费」埋点)。
|
||||
|
||||
**effortEstimate**: 4-6 周(P1 与 P2 可拆分,P1 不依赖安全修复)
|
||||
|
||||
**expectedImpact**: 渠道分销/白标能力预留;ISV 生态接入基础;API 调用计费埋点。
|
||||
|
||||
**kpis**: 白标皮肤落地租户数;开放 API 接入 ISV 数;API 调用计量覆盖率。
|
||||
|
||||
**dependencies**: **P2 必须在 PP-03 Redis 凭据轮换 + PP-07 RLS FORCE + PP-08 连接池修复完成之后**(开放 API 扩大攻击面);API Key 管理需吊销/轮换/审计(否则成为持续安全债务);白标需明确「皮肤级」vs「独立实例」边界(避免过度承诺触碰多租户架构边界)。
|
||||
|
||||
---
|
||||
|
||||
## 四、速赢(1-2 周可落地)
|
||||
|
||||
1. **白标皮肤 P1(branding_json + 主题注入)** — `tenant.settings` 已存在(m000001:28),小程序 `var(--tk-*)` 已就绪,Web ConfigProvider runtime 切换零新依赖。1-2 周可演示,与安全修复解耦可立即推进。把已沉没成本变成销售演示亮点。
|
||||
2. **计量埋点 P1(metering_daily 表 + 5 模块 Meter::record 挂载)** — 复用 `usage.rs:110` `aggregate_daily` 模式 + EventBus,独立连接池避免 RLS 串扰。2 周可让首批租户的 AI/上传/咨询/告警/FHIR 用量进入统一账本,为定价提供真实数据,无需等价格表。
|
||||
3. **积分 decay 参数化 + 行为事件订阅扩充** — `expire_points`(`event.rs:772`)已实现,只需运营定义过期曲线 + 在 `event/points.rs` 加 BLE 上传/连续达标规则。1-2 周,零新表。
|
||||
|
||||
---
|
||||
|
||||
## 五、主题级风险
|
||||
|
||||
1. **计量精度争议**:漏计/重复计引发客户争议。需幂等 rollup(`period+metric_key` 唯一索引)+ 对账报表。`device_active_count` 依赖 PP-02 分区先修好,否则 2026-09 起计量数据全断。
|
||||
2. **配额硬上限阻断医疗业务**:`hard_cap` 402 若误配会拦截 `critical_alert`/`article` 推送等安全相关事件。必须设白名单不参与配额(这会让配额体系出现「holes」,是真实产品权衡)。
|
||||
3. **preset 与 schema 漂移**:preset `seed_data` JSON 与迁移 schema 版本漂移风险高,需 `schema_version` + 增量 patch + 启动校验。导出标杆配置可能含敏感数据(阈值/药品字典),需白名单 + PII 过滤。
|
||||
4. **AI Provider 成本线性增长**:主动关怀 worker 接通后,AI 成本随租户数线性增长,必须配额+降级(`chat_handler` fallback chain)+ 质量门禁(最小可读长度+引用校验,防 AI 输出空/think 块伤害患者体验)。
|
||||
5. **医疗转介绍合规红线**:《医疗广告管理办法》禁止诱导就医,积分必须针对「健康行为」而非「就诊消费」——表述上避免「拉新返现」。HMS 已有 `consent` 拦截器 + `phone_hash` 盲索引是做合规转介绍的天然优势,但需医务审核行为定义。
|
||||
6. **entitlement 漏检=收入损失**:feature 检查点散落各 handler,漏检=免费用户白嫖付费功能。需集中中间件而非散落调用。
|
||||
7. **连接池分片过度设计争议**:架构师会质疑 `tenant_scoped_pool`(按 tenant_id 分片)过度设计,主张 `SET LOCAL IN TRANSACTION`。折中:仅 top-N 活跃租户分片,长尾走 SET LOCAL;但医疗场景间歇性跨租户泄漏哪怕 0.01% 是合规红线。
|
||||
8. **开放 API 安全债务**:API Key 若无吊销/轮换/审计会成为持续安全债务,且必须 PP-03/07/08 修复后才能开放,否则放大攻击面。
|
||||
|
||||
---
|
||||
|
||||
## 六、调和专家分歧后的最终取舍
|
||||
|
||||
### 分歧 1:商业化 vs 基础设施修复的优先级
|
||||
- **SaaS-PMP/增长专家**:主张计量/onboarding 优先,商业化倒逼测试投入。
|
||||
- **测试/安全专家**:主张 PP-10 测试 5.5、PP-01/02/07 等 P0 阻塞项必须先修,否则商业化是空中楼阁。
|
||||
- **最终取舍**:**并行非串行**。Phase 0(上线前 2 周)只做 4 CRITICAL 拆炸弹;Phase 1(1-3 月)计量埋点 + entitlement 抽象与测试门禁、可观测性并行推进。商业化(尤其计量埋点与 onboarding)确实能带来收入和客户压力倒逼投入,但 **402 硬上限/配额中间件/开放 API 等「可阻断业务」的商业化能力严格 gate 在 PP-01/02/07/08 之后**。埋点可先做(只读,无阻断风险)。
|
||||
|
||||
### 分歧 2:连接池分片 vs SET LOCAL
|
||||
- **商业化架构师**:主张 `tenant_scoped_pool` per-tenant 分片。
|
||||
- **架构师**:主张 `SET LOCAL IN TRANSACTION` 即可。
|
||||
- **最终取舍**:**折中方案——top-N 活跃租户分片 + 长尾 SET LOCAL**。医疗场景跨租户泄漏是合规红线,分片工程成本可接受;但全量分片会显著增加 PG `max_connections` 压力,需 PgBouncer transaction pooling 前置。不追求一步到位。
|
||||
|
||||
### 分歧 3:积分通胀(decay)是否伤害体验
|
||||
- **增长专家**:主张积分 12 个月过期制造兑换压力。
|
||||
- **产品/医疗专家**:可能认为过期伤害用户体验。
|
||||
- **最终取舍**:**保留 decay 但分层**。基础积分(签到/上传)可较长过期或不过期;行为奖励积分(连续达标/转介绍)设较短过期(6-12 月)+ 高价值权益兑换(优先挂号/咨询时长)拉动兑换紧迫性。`expire_points` 已实现,调参即可。医疗合规上 decay 不得诱导「为积分过度检查」,需医务审核。
|
||||
|
||||
### 分歧 4:计量计费层是否独立为 erp-billing crate
|
||||
- **架构师**:会反驳计量污染业务模块边界,应独立 crate。
|
||||
- **SaaS-PMP**:主张先在 erp-core 抽 trait + erp-server 轻量 rollup,避免过早引入新 crate(项目已 17 crate)。
|
||||
- **最终取舍**:**先 trait 后 crate**。Phase 1 在 `erp-core` 定义 `EntitlementChecker`/`Meter` trait + `erp-server` 做 rollup 调度,验证商业模式后再视复杂度拆 `erp-billing` crate。不过早增加模块化复杂度。
|
||||
|
||||
### 分歧 5:开放 API 时机
|
||||
- **安全专家**:强烈反对 PP-03/07/08 未修复前推进开放 API。
|
||||
- **SaaS-PMP**:主张 P5 第一步「白标皮肤」与安全风险解耦可并行先做。
|
||||
- **最终取舍**:**拆分 P5**。白标皮肤(branding_json)Phase 1 可立即推进(与安全无关);开放 API 严格 gate 在 PP-03 Redis 轮换 + PP-07 RLS FORCE + PP-08 连接池修复之后(Phase 2-3)。
|
||||
|
||||
### 核心立场(SaaS-PMP 提出且被采纳)
|
||||
SaaS 定价模型一旦随首批医疗客户固化(合同周期长、改价难),后续调整成本极高。**必须在签第 2-5 个客户前确立计量与分层的数据基础**——这是从交付到 SaaS 成败的分水岭。因此计量埋点(举措 1 P1)+ entitlement 抽象(举措 4 P1)必须在首批客户签约前完成,即便 V1 尚未完全稳定。这是商业时间窗口驱动的,不是技术就绪度驱动的。
|
||||
|
||||
---
|
||||
|
||||
## 七、路线(与决策简报 00-INDEX.md 四阶段对齐)
|
||||
|
||||
| 阶段 | 时间 | 本主题交付 |
|
||||
|------|------|-----------|
|
||||
| **Phase 0** | T-0 ~ T+2w | 不阻塞上线。仅可做白标皮肤 P1(branding_json)作销售演示。所有计费/onboarding 推迟。 |
|
||||
| **Phase 1** | T+1M ~ T+3M | 举措 1 P1-P2(计量埋点 + 配额中间件 + 账单导出);举措 3 P1-P2(队列消费者 MVP + 三种触达);举措 4 P1-P2(EntitlementChecker + 套餐定义);举措 2 P1(蓝图表)。**与测试门禁/可观测性并行**。 |
|
||||
| **Phase 2** | T+3M ~ T+6M | 举措 2 P2-P3(provision 端点 + 导出标杆);举措 3 P3-P4(积分行为经济 + 转介绍);举措 4 P3(健康度 + ROI);举措 5 P2(开放 API,安全修复后)。 |
|
||||
| **Phase 3** | T+6M ~ T+12M | 套餐矩阵成熟 + 渠道分销试点 + ISV 生态接入 + 量到价的迭代(基于真实账单数据调价)。连接池分片 top-N 方案落地(配合高可用)。 |
|
||||
|
||||
---
|
||||
|
||||
> 本章节所有论断均经代码核实(文件路径:行号见正文),无臆测。核心增量判断:HMS 的商业化基础设施零件(计量/配额/积分/队列/主题/事件总线)**都已存在但未缝合**,本主题的工作是「接线」而非「造零件」,因此工作量集中在 trait 抽象、中间件、rollup 调度与运营配置,不涉及大规模重写。
|
||||
@@ -4,30 +4,30 @@
|
||||
|
||||
## 关键数字
|
||||
|
||||
> 最后更新: 2026-05-25 | 数据截止: feat/media-library-banner 分支(小程序 DevTools 卡死排查 + 构建优化)
|
||||
> 最后更新: 2026-06-26 | 数据截止: feat/media-library-banner 分支(上线评估 5 阻塞修复:B1 alertmanager 渠道接线 + B4 JWT panic 对称已修,含 doc-code drift 死代码修正)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Rust crate | 17 个(erp-core + 5 基础业务 + erp-health + erp-ai + erp-dialysis + erp-plugin + 7 插件/原型) |
|
||||
| Rust 源文件 | **705 个**(~130,000 行) |
|
||||
| 数据库表 | 30 基础表 + 49 健康业务表 + 13 AI 表(+4 会话/消息/tool_log/user_profile) + 3 媒体库/轮播图表 |
|
||||
| 数据库迁移 | **165 个**(最新 m20260522_000162) |
|
||||
| 后端路由 | **385+ 个**(11 公开 + 14 FHIR + 2 网关 + ~358 受保护) |
|
||||
| Rust 源文件 | **726 个**(~134,000 行) |
|
||||
| 数据库表 | 30 基础表 + 49 健康业务表 + 15 AI 表(+4 会话/消息/tool_log/user_profile + 2 知识库 V2) + 3 媒体库/轮播图表 |
|
||||
| 数据库迁移 | **177 个**(最新 m20260626_000171) |
|
||||
| 后端路由 | **386+ 个**(11 公开 + 14 FHIR + 2 网关 + ~359 受保护) |
|
||||
| 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 3 业务 (health + ai + dialysis) |
|
||||
| erp-health 实体 | **58 个** Entity(31 handler / 57 service / 22 DTO,216 文件) |
|
||||
| erp-ai 实体 | 20 个 Entity(95 文件,4 AI Provider,chat_handler 支持 FC/Ollama fallback) |
|
||||
| 全系统 Entity | **115 个**(58 health + 20 ai + 33 基础 + 4 core) |
|
||||
| erp-health 实体 | **59 个** Entity(33 handler / 57 service / 22 DTO,217 文件) |
|
||||
| erp-ai 实体 | **24 个** Entity(**105 文件**,4 AI Provider,chat_handler 支持 FC/Ollama fallback,知识库 V2) |
|
||||
| 全系统 Entity | **118 个**(59 health + 24 ai + 31 基础 + 4 core) |
|
||||
| Web 前端 | 316 个 TS/TSX 文件(54 活跃路由,83 API 模块,108 页面) |
|
||||
| 微信小程序 | Taro 4.2 + React 18,180 个 TS/TSX 文件 / 61 页面(15 主包 + 46 分包) / 4 TabBar + 医生端独立分包,34 组件(ui 21 + patterns 4 + 独立 9) / 45 service 文件 / 4 Zustand store / 12 hooks,统一组件库 + CSS 变量主题(102 SCSS 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**:Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重 + 分包预加载 preloadRule;**构建优化**:`lazyCodeLoading: requiredComponents` 仅生产构建启用(dev 下已知 DevTools 卡死 bug),`addChunkPages` 仅 TabBar 页注入 common chunk,主包 dev 892KB / prod 766KB(taro.js 526→131KB / vendors.js 230→28KB);**DevTools 兼容**:游客首页 Swiper dev 模式禁用 circular + 间隔 15s,防 DevTools Chromium 渲染进程逐渐卡死;**离线抑制**:指数退避(3s→6s→12s→30s cap)防请求洪泛;**五维度分析评分 6.7/10**(架构7.25/安全6.0/UX7.4/工程6.2) |
|
||||
| 微信小程序 | Taro 4.2 + React 18,**202 个 TS/TSX 文件** / 62 页面(15 主包 + 47 分包) + 1 原生分包页(pkg-veepoo) / 4 TabBar + 医生端独立分包,34 组件(ui 21 + patterns 4 + 独立 9) / **51 service 文件** / **6 Zustand store** / **13 hooks**,统一组件库 + CSS 变量主题(**110 SCSS** 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**:Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重;**Veepoo M2 BLE 管线**:独立管线(VeepooBridge 24 API(含精准睡眠/自动测量/开关设置/体温自动数据)+ VeepooPipeline 事件路由(type=1/4/5/6/18/31/51/54/58)+ VeepooHistoryReader 日常+睡眠上传 + VeepooStore 状态管理(含 sleepData/sleepLoading))+ **原生分包页面**(`pkg-veepoo` 原生 JS+WXML,脱离 Taro 直接调用 SDK,绕过框架兼容性限制)+ **自动测量队列**(连接认证后自动依次测量心率→血氧→血压→体温→压力 5 项指标,列表式进度 UI,面向中老年人零操作设计)+ 3 天历史数据同步(VeepooHistoryReader 分批上传 + 断点续传)+ **精准睡眠数据自动读取**(认证后自动读取 3 天睡眠:深睡/浅睡/总时长/质量评分,通过 Storage 回传 Taro 页面)+ **自动测量功能**(认证后自动开启心率/血压/体温自动监测);**UI 重构**:测量页药丸式选择器 + SVG 圆环仪表盘 + 健康评估标签;数据上传页 2 列结果卡片网格 + 彩色条标识 + 睡眠数据卡片(★ 评分 + 总时长);**preloadRule 已移除 pkg-health** 防止 380KB SDK 预加载导致首页 DevTools 卡死;**构建优化**:`lazyCodeLoading: requiredComponents` 仅生产构建启用(dev 下已知 DevTools 卡死 bug),`addChunkPages` 仅 TabBar 页注入 common chunk,主包 dev 892KB / prod 766KB;**五维度分析评分 6.7/10**(架构7.25/安全6.0/UX7.4/工程6.2) |
|
||||
| 前端测试 | Web 62 单元测试文件(~693 断言) + 17 E2E spec(13 Web + 4 MP,~64 断言);小程序 12 单元测试文件(127 断言) + 4 E2E spec(~16 断言),覆盖率 ~6% |
|
||||
| 后端测试 | **1030 个函数**(839 同步 + 191 异步),96 个文件含测试 |
|
||||
| 事件系统 | 31 事件类型(health)/ 51 全系统 / 82 发布点 / 15 消费者模块 / Outbox + LISTEN/NOTIFY |
|
||||
| 权限码 | **141 个**(health 57 + ai 21 + auth 24 + config 18 + workflow 8 + message 5 + plugin 2 + dialysis 5 + system 1) |
|
||||
| utoipa 注解 | **94 个**文件含注解 |
|
||||
| 后端测试 | **1037 个函数**(839 同步 + 198 异步),98 个文件含测试 |
|
||||
| 事件系统 | 32 事件类型(health)/ 52 全系统 / 83 发布点 / 15 消费者模块 / Outbox + LISTEN/NOTIFY |
|
||||
| 权限码 | **142 个**(health 58 + ai 21 + auth 24 + config 18 + workflow 8 + message 5 + plugin 2 + dialysis 5 + system 1) |
|
||||
| utoipa 注解 | **98 个**文件含注解 |
|
||||
| Clippy | **全 workspace 0 警告**(2026-05-07 清零) |
|
||||
| 依赖版本 | 全部最新主版本线(Rust edition 2024) |
|
||||
| API 文档 | `http://localhost:3000/api/docs/openapi.json` |
|
||||
| Git 提交 | **996 次** |
|
||||
| Git 提交 | **1,065 次** |
|
||||
| Graphify 知识图谱 | **18,517 节点** / 22,666 边 / 1,841 社区(`graphify-out/`,AST 解析,无 API 成本) |
|
||||
| 系统分析评分 | **6.9/10 (B)**(多专家组生产就绪度分析,2026-05-21:业务 8.5 / 医疗合规 6.5 / 前端 8.0 / 安全 7.5 / DevOps 4.0) |
|
||||
| 审计状态 | V1: 83% → V2: 85%,P0 安全修复已完成;E2E 测试 157 端点(Health 63% / AI+Plugin 92.4%),CRITICAL×2 待修复 |
|
||||
@@ -35,8 +35,8 @@
|
||||
| Design Token | 11 级字号 + 12 结构 token + 5 动画时序 token(duration/easing),75 SCSS 页面全量接入 `var(--tk-*)`,`.doctor-mode` / `.elder-mode` CSS 变量级联覆盖,构建时生成 `token-values.ts` 供 Canvas/JS 运行时,ContentCard 支持 padding+margin prop |
|
||||
| 长者模式 | 58/58 页面 100% 覆盖 |
|
||||
| UI 合规审计 | T40: 60 页面全覆盖(PASS 24 / PASS_WITH_ISSUES 36 / NEEDS_WORK 0),HIGH×2 + MEDIUM×6 + LOW×67 全部修复,评分 95/100 |
|
||||
| DevOps 基础设施 | Nginx TLS 1.2/1.3 + HSTS/CSP 安全头 + AES-256-CBC 备份加密 + Prometheus 4 组告警规则 + Redis AOF 持久化 + Grafana Dashboard + uploads 定时备份 |
|
||||
| 项目阶段 | **V1 CONDITIONAL GO** — DevOps P0 阻塞项已加固(TLS + 备份加密 + 监控),剩余 P1 约 3 天(ICD 校验 + 药品编码 + 运维完善) |
|
||||
| DevOps 基础设施 | Nginx TLS 1.2/1.3 + HSTS/CSP 安全头 + AES-256-CBC 备份加密 + Prometheus 4 组告警规则 + **Alertmanager 告警出口(expand-env,渠道上线前填)** + Redis AOF 持久化 + **Grafana dashboard 自动 provisioning(HMS 概览)** + **postgres/redis exporter** + uploads 定时备份 |
|
||||
| 项目阶段 | **V1 CONDITIONAL_GO 6.4/10** — 上线评估 5 阻塞:**B1 alertmanager 渠道接线(代码层已修,待 staging 填真实 webhook)** / B2 Nginx X-Real-IP(运维配置) / B3 git 历史清洗 Redis 凭据(待云端换强密码) / **B4 JWT panic 对称防护(已修,含 doc-code drift 死代码修正)** / **B5 个保法 §45 数据可携权导出(已实现,6 测试全绿)**;§47 删除权待后续(匿名化平衡病历保留义务);可合并 main,staging 加固后灰度上线 |
|
||||
|
||||
## 症状导航
|
||||
|
||||
@@ -148,6 +148,26 @@
|
||||
| TS 编译错误 `readonly Tab[]` 不可赋值给 `Tab[]` | [[miniprogram]] SegmentTabs | 页面组件用 `as const` 创建的 readonly 数组无法传入 mutable `Tab[]` 类型 | **已修复:** SegmentTabs 的 `Tab` 属性改为 `readonly` + `tabs` prop 改为 `readonly Tab[]`(2026-05-24) |
|
||||
| 重建失败 `dist/` 被锁定 | [[miniprogram]] 构建流程 | 微信 DevTools 进程持有 dist 目录文件句柄,taro build 无法写入 | **解决:** `taskkill /F /IM wechatdevtools.exe` 后重新构建(2026-05-24) |
|
||||
| DevTools 打开即卡死(所有项目,Taro/原生均复现) | [[miniprogram]] appid 配置 | appid `wx20f4ef9cc2ec66c5` 的微信后台配置触发 `WAServiceMainContext.js` 内部 timeout,导致 DevTools 渲染进程逐渐无响应;**根因定位:** 换用其他 appid(如测试 appid `wx97debf52c9547da4`)后 Taro/原生均不卡死,确认是 appid 后台服务配置问题而非框架/代码问题 | **待解决:** 需到微信公众平台(mp.weixin.qq.com)检查该 appid 是否开通了云开发/云函数/第三方插件等导致 DevTools 初始化时连接超时的服务;临时方案:开发调试时使用测试 appid(2026-05-24) |
|
||||
| 首页加载后 DevTools 卡死(Veepoo SDK 预加载触发) | [[miniprogram]] preloadRule | `preloadRule` 首页预加载 `pkg-health` 分包 → `sub-vendors.js` 含 380KB Veepoo SDK → SDK 初始化调用蓝牙 API → DevTools Chromium 渲染进程卡死 | **已修复:** 从 `preloadRule` 首页/健康页移除 `pkg-health` 预加载,SDK 仅在用户导航到设备同步/测量页时才加载(2026-05-30) |
|
||||
| 原生页面 `??` 运算符报 SyntaxError | [[miniprogram]] 原生页面 | 微信小程序 JS 引擎不支持 ES2020 `??`(nullish coalescing)和 `?.`(optional chaining) | **已修复:** `values.systolic ?? '--'` 改为 `values.systolic != null ? values.systolic : '--'`(2026-05-30) |
|
||||
| veepoo-measure 页面空白(useRef is not defined) | [[miniprogram]] 原生页面桥接 | TSX 文件使用 `useRef` 但仅 `import React from 'react'` 未解构导入 | **已修复:** 改为 `import React, { useRef } from 'react'`(2026-05-30) |
|
||||
| M2 设备扫描不到(名称匹配过严) | [[miniprogram]] 原生页面扫描 | 过滤条件 `name.indexOf('M2')` 过严,设备可能广播为 VPM/VEEPOO | **已修复:** 放宽匹配 M2/VPM/VEEPOO 三种前缀(2026-05-30) |
|
||||
| M2 设备认证超时(3 层根因) | [[miniprogram]] 原生页面认证 | **根因链**:①连接回调 `errno:0` 在第 1 次回调就匹配,认证在特征值订阅前发送 → 修复为只匹配 `connection:true`;②`veepooWeiXinSDKNotifyMonitorValueChange` 在 `onLoad` 注册时内部调用 `wx.notifyBLECharacteristicValueChange`,适配器未初始化 → `not init` 错误,改到 `connection:true` 后注册;③认证结果字段检查错误:代码检查 `VPDevicepassword`(值="0000")而非 `VPDeviceAck`(值="successfulVerification") | **已修复:** 三层修复 — connection:true 唯一匹配 + 监听器时序 + VPDeviceAck 字段(2026-05-30) |
|
||||
| Veepoo 上传按钮无响应(无日志无报错) | [[miniprogram]] veepoo-measure | `handleUpload` 中 `if (!patient) return;` 静默退出,`currentPatient` 从 auth store 恢复可能为 null(原生页返回后) | **已修复:** patientId 增加 URL 参数 fallback + 每个 early return 添加 console.warn + Taro.showToast 用户提示 + 上传按钮 disabled/loading 状态(2026-05-31) |
|
||||
| M2 测量页仪表盘数值不可见 | [[miniprogram]] 原生测量页 WXSS | `.gauge__center` 无背景色,`conic-gradient` 填满整个圆形区域,数值文字对比度极低 | **已修复:** `.gauge__center` 添加 `background: var(--bg)` + `border-radius: 50%`(2026-06-04) |
|
||||
| 微信登录后显示"绑定失败 — 登录态丢失" | [[miniprogram]] auth store | `login()` catch 块把 API 错误吞掉返回 false,调用方误判为"未绑定"显示绑定按钮;`bindPhone()` 读不到 `wechat_openid` | **已修复:** API 失败时 throw 而非 return false + 增加 `resp.openid` 空值校验(2026-06-04) |
|
||||
| wx_* 患者混入用户管理 | [[erp-auth]] wechat_service | 微信登录创建 `users` 记录 + `patient` 角色,与内部员工混在一起 | **已修复:** `list_users` 新增 `exclude_only_roles` 参数,前端默认排除纯患者用户(2026-06-05) |
|
||||
| 小程序上传数据看不到 | [[erp-health]] patient_service | `list_summaries` 无 user_id 过滤,小程序取到别人的 patient 作为 currentPatient | **已修复:** `list_summaries` 增加 `user_id` 参数,小程序传入当前用户 ID 过滤(2026-06-05) |
|
||||
| 真机微信登录"请求过于频繁" | [[erp-server]] rate_limit | 微信登录与密码登录共享 5 次/分限制 + `extract_client_ip` 无代理头返回 "unknown" 导致所有真机共享同一个 key | **已修复:** 微信登录路由独立为 `wechat_routes`,30 次/分钟宽松限流(2026-06-05) |
|
||||
| Redis 凭据泄露(明文密码进 git) | [[infrastructure]] 凭据管理 | wiki 历史版本明文写 Redis 密码+公网 IP,已进 main 主干+origin/main(最早 2026-04-18) | **核实降级**:泄露旧密码已失效(当前 requirepass 为另一弱密码,未泄露入仓库);HMS 当前连 localhost 本地 Redis(dev.ps1),云端实例闲置无数据无入侵;**已处理**:明文 redact 4 处 + 公网访问已关闭(2026-06-25);**待办(上线前)**:云端换强密码 + compose 配置对齐;**不重写主干历史**(轮换后历史密码无效);**真问题**:doc-code drift(wiki 说云端实际本地) |
|
||||
| device_readings 分区硬截止 2026-09 | [[database]] 分区维护 | m000073 只静态建到 2026_08 分区,无 pg_partman/cron 创建未来分区,2026-09-01 起 INSERT 抛错 | **已修复:** m20260626_000170 补建 2026_09~2027_06 共 10 个月分区(2026-06-26);中期需 pg_partman 自动维护 |
|
||||
| AI 分析队列 claim_next SQL 注入 | [[erp-ai]] analysis_queue | `claim_next` 用 `format!` 拼 tenant_id + SELECT/UPDATE 不在事务内无 SKIP LOCKED | **已修复:** 参数化 `$1` + 事务内 `FOR UPDATE SKIP LOCKED` 原子 claim(2026-06-26) |
|
||||
| 告警触发无人知晓(生产盲飞) | [[infrastructure]] 可观测性 | 11 条 Prometheus 规则无 Alertmanager 通知出口 + Grafana provisioning 空 + exporter 未部署("配置齐全运行为零") | **部分修复:** Alertmanager 服务 + prometheus alerting + Grafana dashboard provisioning(HMS 概览)+ postgres/redis exporter;通知渠道待上线前填(2026-06-26) |
|
||||
| 死信重试未接线(事件永久滞留) | [[erp-core]] events | retry_dead_letters 已实现但 tasks.rs 零调用,危急值告警/积分发放等瞬时故障永久滞留死信表 | **已修复:** start_retry_dead_letters 每小时调度(最大重试 5 次)+ main.rs 注册 + cron_heartbeat 就绪门禁(2026-06-26) |
|
||||
| AI 分析队列只入队不消费(死存储) | [[erp-ai]] analysis_queue | 两个入队源(事件驱动 + auto_analysis)但 claim_next 零调用,job 永远 pending | **已修复:** analysis_worker 后台消费者(claim→analysis_type 路由→AnalysisService→complete),MVP 打通 trend 链路(2026-06-26) |
|
||||
| 工作台死链 + value={0} 假数据 | [[frontend]] StatisticsDashboard | feat 重构引入死链 navigate(routeConfig 不存在路由)+ 线下活动恒显示 0 | **已修复:** 5 处死链校正路由 + 线下活动接入真实 offlineEventCount(2026-06-26) |
|
||||
| JWT 默认密钥检查永不命中(doc-code drift 死代码) | [[erp-server]] config.rs | `AppConfig::load` 检查常量 `change-me-in-production` 在仓库无任何注入源;实际 dev 值(`dev-secret-key-change-in-prod`)、占位符(`__MUST_SET_VIA_ENV__`)均不匹配 → warn 从不触发,生产忘设 `ERP__JWT__SECRET` 时静默运行可伪造任意 token | **已修复:** 改为 `UNSAFE_JWT_SECRETS` 集合检查(占位符 + dev.ps1 dev 值 + 历史值),release panic 与 KEK(`main.rs:453`)/storage_key(`config.rs:135`) 防护对称(2026-06-26) |
|
||||
| Alertmanager 告警渠道占位(医疗告警盲飞) | [[infrastructure]] alertmanager | `config.yml` 写死 `placeholder.invalid` **且** alertmanager 容器无 `environment`/`env_file` 注入 `ALERT_WEBHOOK_URL`(虽有 `--config.expand-env=true` 但容器内无变量可展开,评估漏检此层) | **已修复(代码层):** `config.yml`→`${ALERT_WEBHOOK_URL}` + compose 注入 `environment`(未配置时 fallback 占位保 MVP 可启动)+ `.env.production.example` 补模板;上线前在 `.env.production` 填真实钉钉/企微 webhook 即生效(2026-06-26) |
|
||||
|
||||
## 模块导航
|
||||
|
||||
@@ -165,7 +185,7 @@
|
||||
- erp-plugin — WASM 运行时 · 动态表 · 热更新(HMS 保留但非主要扩展方式)
|
||||
|
||||
### 核心业务层(HMS 专属)
|
||||
- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理 · 内容管理 · 媒体库 · 轮播图管理 · 积分商城 · 透析管理 · 线下活动 · 日常监测 · 告警系统**(原生 Rust 模块,57 实体 / 31 handler / 36 service,已实现)
|
||||
- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理 · 内容管理 · 媒体库 · 轮播图管理 · 积分商城 · 透析管理 · 线下活动 · 日常监测 · 告警系统 · 个保法 §45 数据可携权导出**(原生 Rust 模块,57 实体 / 31 handler / 36 service,已实现)
|
||||
- [[erp-ai]] — **AI 智能分析 · 化验单解读 · 趋势分析 · 报告摘要 · AI 对话(ChatPage+AiSidebar) · 会话持久化**(原生 Rust 模块,9 实体 / 62 文件 / 4 AI Provider / chat_handler FC+fallback,Phase 1 MVP)
|
||||
|
||||
### 组装层
|
||||
@@ -194,6 +214,42 @@
|
||||
|
||||
**患者/医护与 erp-auth 的关系?** 账号走 `users` 表,erp-health 通过 `user_id` 外键关联扩展字段(科室、职称、档案等)。患者可先建档后绑定账号。
|
||||
|
||||
## 上线后待办
|
||||
|
||||
> 2026-06-05 会议讨论确定,合并到 main 前后需要逐项处理。
|
||||
|
||||
### 必须项(上线前/上线时)
|
||||
|
||||
| # | 待办 | 关联提交 | 说明 |
|
||||
|---|------|---------|------|
|
||||
| 1 | **重启后端** | `01a0fffc` | 用户管理过滤 + 患者摘要过滤 + 微信限流修复,三项都需要重启生效 |
|
||||
| 2 | **重新构建小程序** | `1982698b` | `getPatientSummaries` 新增 `userId` 参数,需 `pnpm dev:weapp` 或 `build:weapp` |
|
||||
| 3 | **Nginx 配置 X-Real-IP** | `01a0fffc` | `extract_client_ip` 无代理头时 fallback 为 `"unknown"`,所有真机共享限流 key。Nginx 必须添加 `proxy_set_header X-Real-IP $remote_addr;`,否则限流形同虚设 |
|
||||
| 4 | **真机验证微信登录** | — | 用 7141 真机登录,确认不再触发"请求过于频繁" |
|
||||
| 5 | **真机验证数据上传** | `1982698b` | 7141 登录后连接 M2 手环上传数据,确认数据关联到正确的 patient |
|
||||
| 6 | **用户管理页面验证** | `201a9158` | 确认 `wx_*` 患者不再出现在用户管理列表,内部员工正常显示 |
|
||||
|
||||
### 建议项(上线后尽快)
|
||||
|
||||
| # | 待办 | 说明 |
|
||||
|---|------|------|
|
||||
| 7 | **清理 `wx__cod` 脏数据** | 用户名 `wx__cod` / 手机号 `1380000_cod` 是早期测试遗留,`users` 表有记录但无 `patient`,也无 `user_credentials`。建议软删除 |
|
||||
| 8 | **统一早期 patient 命名** | `wx_7141` 的 patient 名叫 "MP User 7141"(早期测试命名),其他都是 "微信用户XXXX"。建议 SQL 统一为 `微信用户XXXX` 格式 |
|
||||
| 9 | **检查其他 wx_* 用户数据归属** | `device_readings` 中有 73 条数据属于 `wx_6897`(69条) 和 `wx_6391`(4条)。旧代码可能将数据写到了错误的 patient 下,需核实这些用户是否真的上传了自己的数据 |
|
||||
| 10 | **`extract_client_ip` 改进** | 当前无代理头时返回 `"unknown"`,所有客户端共享限流配额。生产环境依赖 Nginx 头即可,但开发环境应考虑从 Axum `ConnectInfo` 获取真实 IP |
|
||||
|
||||
### 数据修复 SQL(上线后按需执行)
|
||||
|
||||
```sql
|
||||
-- #7: 软删除 wx__cod 脏数据
|
||||
UPDATE users SET deleted_at = NOW(), status = 'disabled'
|
||||
WHERE username = 'wx__cod' AND deleted_at IS NULL;
|
||||
|
||||
-- #8: 统一 7141 的 patient 名称
|
||||
UPDATE patient SET name = '微信用户7141'
|
||||
WHERE id = '14f34cc3-7a16-48c0-bafc-9bb0989d5fbd' AND name = 'MP User 7141';
|
||||
```
|
||||
|
||||
## 文档索引
|
||||
|
||||
| 类型 | 位置 |
|
||||
|
||||
@@ -32,7 +32,7 @@ tags: [infrastructure, dev-environment, windows, postgresql]
|
||||
| 服务 | 地址 | 用途 |
|
||||
|------|------|------|
|
||||
| PostgreSQL 16 | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 |
|
||||
| Redis 7 | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 |
|
||||
| Redis 7 | 连接串通过 `ERP__REDIS__URL` 环境变量注入(云端 Redis,**禁止硬编码 host/密码**) | 缓存 + 限流 |
|
||||
| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 |
|
||||
| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 |
|
||||
| 微信小程序 | 微信开发者工具 | 患者端小程序(`apps/miniprogram/dist/`) |
|
||||
@@ -54,7 +54,9 @@ psql: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp`
|
||||
| `ERP__DATABASE__URL` | `postgres://postgres:123123@localhost:5432/erp` |
|
||||
| `ERP__JWT__SECRET` | `dev-secret-key-change-in-prod` |
|
||||
| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | `Admin@2026` |
|
||||
| `ERP__REDIS__URL` | `redis://:redis_KBCYJk@129.204.154.246:6379` |
|
||||
| `ERP__REDIS__URL` | `redis://:<密码>@<云端 host>:6379`(开发用 `redis://localhost:6379` 无密码;生产密码经密钥管理注入,**禁止入仓库**) |
|
||||
|
||||
> ⚠️ **安全告警(2026-06-25 凭据泄露事件,已降级)**:本页历史版本曾明文写入 Redis 密码与公网 IP,仓库明文已清除。**核实结论**:① 泄露的旧密码 `redis_KBCYJk` 当前已失效(实际 requirepass 为另一值,未在仓库出现);② HMS 运行时连**本地** `localhost:6379`(`dev.ps1`),云端 Redis 实例闲置、数据为空、无入侵征兆,**公网访问已于 2026-06-25 关闭**。**剩余待办(上线前)**:云端 Redis 换强密码(当前为弱密码)+ `docker-compose.production.yml` 的 `@redis` 假设需对齐「外部云端 Redis」实际架构。历史 commit 仍含旧密码但已无效。详见 [[index]] 症状导航「Redis 凭据泄露」。
|
||||
| `ERP__WECHAT__APPID` | `wx20f4ef9cc2ec66c5` |
|
||||
| `ERP__WECHAT__SECRET` | 微信小程序 Secret |
|
||||
| `ERP__WECHAT__DEV_MODE` | `true`(开发时跳过 jscode2session,允许 DevTools 模拟器登录) |
|
||||
|
||||
416
wiki/permissions.md
Normal file
416
wiki/permissions.md
Normal file
@@ -0,0 +1,416 @@
|
||||
---
|
||||
title: 角色权限体系
|
||||
updated: 2026-05-22
|
||||
status: active
|
||||
tags: [permissions, roles, rbac, frontend, miniprogram]
|
||||
---
|
||||
|
||||
> 2026-05-22 更新:补齐 patient 角色小程序端 manage 权限(15 项),注册 `system.analytics.submit` 幽灵权限,新增 §6.4 医生端小程序权限矩阵。
|
||||
|
||||
# 角色权限体系
|
||||
|
||||
> 从 [[index]] 导航。关联: [[architecture]] [[erp-health]] [[frontend]] [[miniprogram]]
|
||||
|
||||
## 1. 概述
|
||||
|
||||
HMS 采用 **RBAC(基于角色的访问控制)** 模型,包含 7 个系统角色,约 141+ 权限码覆盖 auth / config / workflow / message / plugin / health / ai / copilot / dialysis / system 十大模块。
|
||||
|
||||
权限执行层分两端:
|
||||
- **Web 管理后台**:权限码守卫(`routeConfig.ts` 声明 `permissions: [...]`),后端 handler 层 `require_permission` 强制校验
|
||||
- **微信小程序**:角色码守卫(`isMedicalStaff()` / `isDoctor()` / `isNurse()` / `isHealthManager()`),患者端按角色分流
|
||||
|
||||
### 数据范围(data_scope)
|
||||
|
||||
| 角色 | data_scope | 说明 |
|
||||
|------|-----------|------|
|
||||
| admin | `all` | 全部数据 |
|
||||
| viewer | `all` | 全部数据(只读) |
|
||||
| doctor | `all` | 全部患者数据 |
|
||||
| nurse | `all` | 全部患者数据 |
|
||||
| health_manager | `all` | 全部患者数据 |
|
||||
| operator | `all` | 全部数据 |
|
||||
| patient | `self` | 仅本人数据 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 角色定义
|
||||
|
||||
| 角色码 | 名称 | 定位 | 典型用户 |
|
||||
|--------|------|------|----------|
|
||||
| `admin` | 系统管理员 | 全部权限,系统配置与用户管理 | IT 管理员 |
|
||||
| `viewer` | 查看者 | 只读权限,查看基础数据 | 上级领导、审计 |
|
||||
| `doctor` | 医生 | 患者诊疗、诊断、随访、咨询、透析 | 临床医生 |
|
||||
| `nurse` | 护士 | 患者护理、日常监测、体征录入、设备 | 临床护士 |
|
||||
| `health_manager` | 健康管理师 | 全流程健康管理、告警规则、AI 分析 | 健康管理师 |
|
||||
| `operator` | 运营人员 | 内容管理、积分、媒体、轮播图 | 运营/编辑 |
|
||||
| `patient` | 患者 | 小程序自助服务(仅本人数据) | 患者端用户 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 权限码总表
|
||||
|
||||
### 3.1 基础模块(auth / config / workflow / message / plugin)
|
||||
|
||||
| 模块 | 权限码 | 说明 |
|
||||
|------|--------|------|
|
||||
| **用户管理** | `user.list` `user.create` `user.read` `user.update` `user.delete` | 用户 CRUD |
|
||||
| **角色管理** | `role.list` `role.create` `role.read` `role.update` `role.delete` | 角色 CRUD |
|
||||
| **权限管理** | `permission.list` | 权限列表(只读) |
|
||||
| **组织管理** | `organization.list` `organization.create` `organization.update` `organization.delete` | 组织架构 |
|
||||
| **部门管理** | `department.list` `department.create` `department.update` `department.delete` | 部门 CRUD |
|
||||
| **岗位管理** | `position.list` `position.create` `position.update` `position.delete` | 岗位 CRUD |
|
||||
| **字典管理** | `dictionary.list` `dictionary.create` `dictionary.update` `dictionary.delete` | 数据字典 |
|
||||
| **菜单管理** | `menu.list` `menu.update` | 菜单配置 |
|
||||
| **系统设置** | `setting.read` `setting.update` `setting.delete` | 系统参数 |
|
||||
| **编号规则** | `numbering.list` `numbering.create` `numbering.update` `numbering.delete` `numbering.generate` | 编号序列 |
|
||||
| **主题** | `theme.read` `theme.update` | UI 主题 |
|
||||
| **语言** | `language.list` `language.update` | 国际化 |
|
||||
| **工作流** | `workflow.create` `workflow.list` `workflow.read` `workflow.update` `workflow.publish` `workflow.start` `workflow.approve` `workflow.delegate` | BPMN 流程 |
|
||||
| **消息** | `message.list` `message.send` `message.template.list` `message.template.create` `message.template.manage` | 消息通知 |
|
||||
| **插件** | `plugin.admin` `plugin.list` | WASM 插件管理 |
|
||||
| **租户** | `tenant.manage` | 多租户管理 |
|
||||
|
||||
### 3.2 健康模块(health)
|
||||
|
||||
| 子域 | 权限码 | 说明 |
|
||||
|------|--------|------|
|
||||
| **患者** | `health.patient.list` `health.patient.manage` | 患者档案 |
|
||||
| **健康数据** | `health.health-data.list` `health.health-data.manage` | 体征/化验 |
|
||||
| **预约** | `health.appointment.list` `health.appointment.manage` | 预约排班 |
|
||||
| **随访** | `health.follow-up.list` `health.follow-up.manage` | 随访任务 |
|
||||
| **咨询** | `health.consultation.list` `health.consultation.manage` | 在线咨询 |
|
||||
| **医生** | `health.doctor.list` `health.doctor.manage` | 医生管理 |
|
||||
| **诊断** | `health.diagnosis.list` `health.diagnosis.manage` | 诊断记录 |
|
||||
| **日常监测** | `health.daily-monitoring.list` `health.daily-monitoring.manage` | 每日监测 |
|
||||
| **告警** | `health.alerts.list` `health.alerts.manage` | 告警列表 |
|
||||
| **告警规则** | `health.alert-rules.list` `health.alert-rules.manage` | 告警规则配置 |
|
||||
| **危急值** | `health.critical-alerts.list` `health.critical-alerts.manage` | 危急值告警 |
|
||||
| **危急值阈值** | `health.critical-value-thresholds.list` `health.critical-value-thresholds.manage` | 阈值设置 |
|
||||
| **随访模板** | `health.follow-up-templates.list` `health.follow-up-templates.manage` | 模板管理 |
|
||||
| **知情同意** | `health.consent.list` `health.consent.manage` | 同意管理 |
|
||||
| **用药记录** | `health.medication-records.list` `health.medication-records.manage` | 用药记录 |
|
||||
| **用药提醒** | `health.medication-reminders.list` `health.medication-reminders.manage` | 提醒设置 |
|
||||
| **行动收件箱** | `health.action-inbox.list` `health.action-inbox.manage` `health.action-inbox.team` | 待办任务 |
|
||||
| **仪表盘** | `health.dashboard.manage` | 统计仪表盘 |
|
||||
| **OAuth** | `health.oauth.list` `health.oauth.manage` | 第三方授权 |
|
||||
| **关怀计划** | `health.care-plan.list` `health.care-plan.manage` | 关怀计划 |
|
||||
| **排班** | `health.shifts.list` `health.shifts.manage` | 医护排班 |
|
||||
| **BLE 网关** | `health.ble-gateways.list` `health.ble-gateways.manage` | 蓝牙网关 |
|
||||
| **家庭代理** | `health.family-proxy.list` `health.family-proxy.manage` | 家属代管 |
|
||||
| **媒体库** | `health.media.list` `health.media.manage` | 媒体文件 |
|
||||
| **轮播图** | `health.banners.list` `health.banners.manage` | 首页轮播 |
|
||||
| **标签** | `health.tags.list` `health.tags.manage` | 患者标签 |
|
||||
| **设备** | `health.devices.list` `health.devices.manage` | 设备管理 |
|
||||
| **设备读数** | `health.device-readings.list` `health.device-readings.manage` | 设备数据 |
|
||||
| **透析** | `health.dialysis.list` `health.dialysis.manage` | 透析记录 |
|
||||
| **透析处方** | `health.dialysis-prescription.list` `health.dialysis-prescription.manage` | 透析处方 |
|
||||
| **透析统计** | `health.dialysis.stats` | 透析统计 |
|
||||
| **线下活动** | `health.offline-events.list` `health.offline-events.manage` | 线下活动 |
|
||||
| **文章** | `health.articles.list` `health.articles.manage` `health.articles.review` | 内容管理 |
|
||||
| **积分** | `health.points.list` `health.points.manage` | 积分商城 |
|
||||
| **统计** | `health.stats.list` | 健康统计 |
|
||||
|
||||
### 3.3 AI 模块(ai)
|
||||
|
||||
| 权限码 | 说明 |
|
||||
|--------|------|
|
||||
| `ai.analysis.list` `ai.analysis.manage` | AI 分析 |
|
||||
| `ai.prompt.list` `ai.prompt.manage` | 提示词管理 |
|
||||
| `ai.provider.manage` | AI Provider 配置 |
|
||||
| `ai.suggestion.list` `ai.suggestion.manage` | AI 建议 |
|
||||
| `ai.usage.list` | AI 用量统计 |
|
||||
| `ai.chat.send` | AI 对话 |
|
||||
| `ai.config.read` `ai.config.manage` | AI 配置 |
|
||||
| `ai.knowledge.list` `ai.knowledge.manage` | 知识库 |
|
||||
| `ai.admin.dashboard` `ai.admin.flags` | AI 管理后台 |
|
||||
|
||||
### 3.4 Copilot 模块
|
||||
|
||||
| 权限码 | 说明 |
|
||||
|--------|------|
|
||||
| `copilot.insights.list` `copilot.insights.manage` | Copilot 洞察 |
|
||||
| `copilot.risk.view` | 风险查看 |
|
||||
| `copilot.rules.list` `copilot.rules.manage` | Copilot 规则 |
|
||||
|
||||
### 3.5 透析模块(dialysis)
|
||||
|
||||
| 权限码 | 说明 |
|
||||
|--------|------|
|
||||
| `health.dialysis.list` `health.dialysis.manage` | 透析管理 |
|
||||
| `health.dialysis-prescription.list` `health.dialysis-prescription.manage` | 透析处方 |
|
||||
| `health.dialysis.stats` | 透析统计 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 各角色权限矩阵
|
||||
|
||||
> `+` = 拥有,`-` = 不拥有。admin 拥有全部权限,不再逐一列出。
|
||||
|
||||
### 4.1 医生(doctor)
|
||||
|
||||
| 子域 | list | manage | 特殊 |
|
||||
|------|:----:|:------:|------|
|
||||
| 患者 | + | + | |
|
||||
| 健康数据 | + | - | 仅查看 |
|
||||
| 预约 | + | + | |
|
||||
| 随访 | + | + | |
|
||||
| 咨询 | + | + | |
|
||||
| 医生 | + | + | |
|
||||
| 诊断 | + | + | |
|
||||
| 日常监测 | + | + | |
|
||||
| 告警 | + | + | |
|
||||
| 告警规则 | + | - | 仅查看 |
|
||||
| 危急值 | + | - | 仅查看 |
|
||||
| 知情同意 | + | + | |
|
||||
| 随访模板 | + | + | |
|
||||
| 行动收件箱 | + | + | |
|
||||
| 关怀计划 | + | + | |
|
||||
| 透析 | + | + | |
|
||||
| 透析处方 | + | + | |
|
||||
| 透析统计 | + | | |
|
||||
| AI 分析 | + | - | 仅查看 |
|
||||
| AI 建议 | + | - | 仅查看 |
|
||||
| AI 提示词 | + | - | 仅查看 |
|
||||
| AI 用量 | + | - | 仅查看 |
|
||||
| 消息 | + | | |
|
||||
| 工作流 | + | | `list` + `read` |
|
||||
|
||||
**无权访问:** 文章管理、积分、标签、媒体库、轮播图、AI 管理、设备管理、线下活动、copilot
|
||||
|
||||
### 4.2 护士(nurse)
|
||||
|
||||
| 子域 | list | manage | 特殊 |
|
||||
|------|:----:|:------:|------|
|
||||
| 患者 | + | + | |
|
||||
| 健康数据 | + | - | 仅查看 |
|
||||
| 预约 | + | + | |
|
||||
| 随访 | + | + | |
|
||||
| 咨询 | + | - | 仅查看 |
|
||||
| 诊断 | + | - | 仅查看 |
|
||||
| 日常监测 | + | + | |
|
||||
| 告警 | + | - | 仅查看 |
|
||||
| 危急值 | + | - | 仅查看 |
|
||||
| 知情同意 | + | + | |
|
||||
| 设备 | + | - | 仅查看 |
|
||||
| 设备读数 | + | - | 仅查看 |
|
||||
| 行动收件箱 | + | + | |
|
||||
| 消息 | + | | |
|
||||
|
||||
**无权访问:** 医生管理、告警管理(manage)、告警规则、AI、文章、积分、标签、媒体库、轮播图、透析、copilot
|
||||
|
||||
### 4.3 健康管理师(health_manager)
|
||||
|
||||
| 子域 | list | manage | 特殊 |
|
||||
|------|:----:|:------:|------|
|
||||
| 患者 | + | + | |
|
||||
| 健康数据 | + | + | |
|
||||
| 医生 | + | - | 仅查看 |
|
||||
| 随访 | + | + | |
|
||||
| 咨询 | + | + | |
|
||||
| 诊断 | + | + | |
|
||||
| 日常监测 | + | + | |
|
||||
| 告警 | + | + | |
|
||||
| 告警规则 | + | + | |
|
||||
| 危急值 | + | - | 仅查看 |
|
||||
| 危急值阈值 | + | - | 仅查看 |
|
||||
| 知情同意 | + | + | |
|
||||
| 随访模板 | + | + | |
|
||||
| 标签 | + | + | |
|
||||
| 设备 | + | - | 仅查看 |
|
||||
| 行动收件箱 | + | + | `team` 额外权限 |
|
||||
| 仪表盘 | + | | `dashboard.manage` |
|
||||
| AI 分析 | + | + | |
|
||||
| AI 建议 | + | + | |
|
||||
| AI 提示词 | + | - | 仅查看 |
|
||||
| AI 用量 | + | - | 仅查看 |
|
||||
| 消息 | + | | |
|
||||
| 工作流 | + | | `list` + `read` + `start` |
|
||||
|
||||
**无权访问:** 预约、透析、文章、积分、媒体库、轮播图、线下活动、copilot、OAuth、关怀计划、排班
|
||||
|
||||
### 4.4 运营人员(operator)
|
||||
|
||||
| 子域 | list | manage | 特殊 |
|
||||
|------|:----:|:------:|------|
|
||||
| 患者 | + | - | 仅查看 |
|
||||
| 标签 | + | + | |
|
||||
| 文章 | + | + | `review` 额外权限 |
|
||||
| 积分 | + | + | |
|
||||
| 设备 | + | - | 仅查看 |
|
||||
| 告警 | + | - | 仅查看 |
|
||||
| 媒体库 | + | + | |
|
||||
| 轮播图 | + | + | |
|
||||
| 仪表盘 | + | | `dashboard.manage` |
|
||||
| AI 用量 | + | - | 仅查看 |
|
||||
| 消息 | + | | |
|
||||
|
||||
**无权访问:** 健康数据、预约、随访、咨询、诊断、日常监测、告警规则、危急值、知情同意、AI 分析、透析、copilot
|
||||
|
||||
### 4.5 患者(patient)
|
||||
|
||||
> data_scope = `self`,所有操作仅限本人数据。通过小程序访问(`m20260522_000162` 完成全量配置)。
|
||||
|
||||
| 子域 | list | manage | 说明 |
|
||||
|------|:----:|:------:|------|
|
||||
| 健康数据 | + | + | 录入体征、查看本人体征/化验 |
|
||||
| 患者 | + | + | 查看/更新本人档案、绑定手机 |
|
||||
| 预约 | + | + | 创建/取消本人预约 |
|
||||
| 医生 | + | - | 预约时选择医生 |
|
||||
| 随访 | + | + | 提交随访记录 |
|
||||
| 咨询 | + | + | 创建咨询会话、发送消息 |
|
||||
| 积分 | + | + | 签到、兑换商品 |
|
||||
| 文章 | + | - | 阅读公开文章 |
|
||||
| 告警 | + | - | 查看本人告警 |
|
||||
| 日常监测 | + | + | 创建日常监测记录 |
|
||||
| 设备读数 | + | + | 上传设备数据 |
|
||||
| 设备 | + | - | 查看绑定设备 |
|
||||
| 知情同意 | + | + | 授权/撤回本人同意 |
|
||||
| 用药记录 | + | - | 查看本人用药 |
|
||||
| 用药提醒 | + | + | CRUD 本人提醒 |
|
||||
| 关怀计划 | + | - | 查看本人计划 |
|
||||
| 行动收件箱 | + | - | 查看本人待办 |
|
||||
| 透析 | + | - | 查看本人透析 |
|
||||
| AI 分析 | + | - | 查看本人分析报告 |
|
||||
| AI 建议 | + | - | 查看分析建议 |
|
||||
| AI 对话 | + | - | `ai.chat.send` + 会话 list/manage |
|
||||
| 消息 | + | - | 查看本人消息 |
|
||||
| 埋点 | - | - | `system.analytics.submit`(data_scope=self) |
|
||||
|
||||
### 4.6 查看者(viewer)
|
||||
|
||||
基础模块只读权限(auth / config / workflow / message / plugin 的 `list`/`read`)。无 health / AI / copilot 权限。
|
||||
|
||||
---
|
||||
|
||||
## 5. Web 前端路由权限
|
||||
|
||||
Web 管理后台通过 `routeConfig.ts` 声明每个路由所需权限码。用户登录后,路由守卫检查其角色是否拥有对应权限。
|
||||
|
||||
### 5.1 各角色可见菜单
|
||||
|
||||
#### admin
|
||||
全部菜单可见。
|
||||
|
||||
#### doctor
|
||||
首页、统计仪表盘、患者管理、日常监测、诊断记录、知情同意、咨询管理、随访任务、随访模板、行动收件箱、告警仪表盘、告警管理、AI 分析、AI 用量、AI 对话、消息
|
||||
|
||||
#### nurse
|
||||
首页、统计仪表盘、患者管理、日常监测、诊断记录、知情同意、咨询管理、随访任务、行动收件箱、告警仪表盘、告警管理、消息
|
||||
|
||||
#### health_manager
|
||||
首页、统计仪表盘、患者管理、日常监测、诊断记录、知情同意、咨询管理、标签管理、医生管理、随访任务、随访模板、行动收件箱、实时监测、告警仪表盘、告警管理、告警规则、设备管理、危急值阈值、AI 提示词、AI 分析、AI 知识库、AI 用量、AI 配置、AI 对话、消息
|
||||
|
||||
#### operator
|
||||
首页、统计仪表盘、患者管理(只读)、标签管理、设备管理、告警仪表盘、告警管理(只读)、文章管理、积分规则、积分商品、积分订单、线下活动、媒体库、轮播图、AI 用量、消息
|
||||
|
||||
---
|
||||
|
||||
## 6. 小程序角色控制
|
||||
|
||||
小程序端采用**角色码**(非权限码)做前端控制,后端仍通过权限码校验 API 请求。
|
||||
|
||||
### 6.1 角色判断函数
|
||||
|
||||
```typescript
|
||||
// stores/auth.ts
|
||||
isMedicalStaff() → roles 含 doctor / nurse / admin / health_manager
|
||||
isDoctor() → roles 含 doctor / admin
|
||||
isNurse() → roles 含 nurse / admin
|
||||
isHealthManager() → roles 含 health_manager / admin
|
||||
hasRole(code) → roles 含 code / admin
|
||||
```
|
||||
|
||||
### 6.2 角色与小程序页面映射
|
||||
|
||||
| 角色 | 可访问页面 |
|
||||
|------|-----------|
|
||||
| **patient** | 首页、健康、咨询、商城、我的(5 TabBar)+ 分包页面(预约、随访、告警、积分、文章、设备、AI 对话) |
|
||||
| **doctor / admin** | 首页 → 自动跳转医生端分包(`pkg-doctor-core`)+ 患者管理、咨询、随访、行动收件箱 |
|
||||
| **nurse** | 同 doctor,但部分管理功能降级为只读 |
|
||||
| **health_manager** | 同 doctor,额外可管理告警规则、AI 分析 |
|
||||
|
||||
### 6.3 患者端(patient)API 权限
|
||||
|
||||
患者通过小程序访问以下 API(均需 `patient` 角色且 `data_scope=self`)。完整权限配置见迁移 `m20260522_000162`。
|
||||
|
||||
| 小程序页面 | API 路径 | 所需权限码 | 操作类型 |
|
||||
|-----------|----------|-----------|---------|
|
||||
| 健康总览 | `GET /health/health-data/summary` | `health.health-data.list` | 只读 |
|
||||
| 体征录入 | `POST /health/health-data` | `health.health-data.manage` | 写入 |
|
||||
| 健康趋势 | `GET /health/health-data/trend` | `health.health-data.list` | 只读 |
|
||||
| 我的报告 | `GET /health/patients/{id}/lab-reports` | `health.health-data.list` | 只读 |
|
||||
| AI 解读 | `GET /ai/analyses` | `ai.analysis.list` | 只读 |
|
||||
| 健康档案 | `GET /health/health-records` | `health.health-data.list` | 只读 |
|
||||
| 诊断记录 | `GET /health/diagnoses` | `health.health-data.list` | 只读 |
|
||||
| 我的预约 | `GET /health/appointments` | `health.appointment.list` | 只读 |
|
||||
| 创建预约 | `POST /health/appointments` | `health.appointment.manage` | 写入 |
|
||||
| 医生列表 | `GET /health/doctors` | `health.doctor.list` | 只读 |
|
||||
| 我的随访 | `GET /health/follow-up-tasks` | `health.follow-up.list` | 只读 |
|
||||
| 提交随访 | `POST /health/follow-up-records` | `health.follow-up.manage` | 写入 |
|
||||
| 在线咨询 | `POST /health/consultation-sessions` | `health.consultation.manage` | 写入 |
|
||||
| 咨询消息 | `GET/POST /health/consultation-sessions/{id}/messages` | `health.consultation.list` + `manage` | 读写 |
|
||||
| 告警列表 | `GET /health/alerts` | `health.alerts.list` | 只读 |
|
||||
| 行动收件箱 | `GET /health/action-inbox` | `health.action-inbox.list` | 只读 |
|
||||
| 设备同步 | `POST /health/device-readings` | `health.device-readings.manage` | 写入 |
|
||||
| 药物提醒 | `GET/POST/PUT/DELETE /health/medication-reminders` | `health.medication-reminders.list` + `manage` | 读写 |
|
||||
| 知情同意 | `POST/PUT /health/consents` | `health.consent.list` + `manage` | 读写 |
|
||||
| 积分账户 | `GET /health/points/account` | `health.points.list` | 只读 |
|
||||
| 积分签到 | `POST /health/points/checkin` | `health.points.manage` | 写入 |
|
||||
| 积分兑换 | `POST /health/points/redeem` | `health.points.manage` | 写入 |
|
||||
| 文章列表 | `GET /health/articles` | `health.articles.list` | 只读 |
|
||||
| 消息通知 | `GET /messages` | `message.list` | 只读 |
|
||||
| AI 对话 | `POST /ai/chat/send` | `ai.chat.send` | 写入 |
|
||||
| AI 会话 | `GET/POST /ai/chat/sessions` | `ai.chat.session.list` + `manage` | 读写 |
|
||||
| 埋点上报 | `POST /analytics/batch` | `system.analytics.submit` | 写入 |
|
||||
| 就诊人管理 | `GET/PUT /health/patients/{id}` | `health.patient.list` + `manage` | 读写 |
|
||||
| 日常监测 | `GET/POST /health/daily-monitoring` | `health.daily-monitoring.list` + `manage` | 读写 |
|
||||
|
||||
### 6.4 医生端小程序权限
|
||||
|
||||
医生/护士通过小程序医生端分包(`pkg-doctor-core`)访问以下 API。权限码与 Web 管理后台一致。
|
||||
|
||||
| 小程序页面 | API 路径 | 所需权限码 | 角色要求 |
|
||||
|-----------|----------|-----------|---------|
|
||||
| 医生首页 | `GET /health/doctor/dashboard` | `health.dashboard.manage` | doctor/nurse/hm |
|
||||
| 患者管理 | `GET /health/patients` | `health.patient.list` | doctor/nurse/hm |
|
||||
| 患者详情 | `GET /health/patients/{id}/health-summary` | `health.patient.list` | doctor/nurse/hm |
|
||||
| 咨询列表 | `GET /health/consultation-sessions` | `health.consultation.list` | doctor/nurse/hm |
|
||||
| 咨询回复 | `POST /health/consultation-sessions/{id}/messages` | `health.consultation.manage` | doctor/hm |
|
||||
| 随访管理 | `GET/PUT /health/follow-up-tasks` | `health.follow-up.list` + `manage` | doctor/nurse/hm |
|
||||
| 告警处理 | `POST /health/alerts/{id}/acknowledge` | `health.alerts.manage` | doctor/hm |
|
||||
| 行动收件箱 | `GET /health/action-inbox` | `health.action-inbox.list` | doctor/nurse/hm |
|
||||
| 团队概览 | `GET /health/action-inbox/team` | `health.action-inbox.team` | hm |
|
||||
| 化验报告 | `GET /health/patients/{id}/lab-reports` | `health.health-data.list` | doctor/nurse/hm |
|
||||
| 透析管理 | `GET/POST /health/dialysis-records` | `health.dialysis.list` + `manage` | doctor |
|
||||
| 透析处方 | `GET/POST /health/dialysis-prescriptions` | `health.dialysis-prescription.list` + `manage` | doctor |
|
||||
|
||||
---
|
||||
|
||||
## 7. 权限配置维护
|
||||
|
||||
### 7.1 新增权限码流程
|
||||
|
||||
1. 在对应模块 `module.rs` 的 `PermissionDescriptor` 中声明权限码
|
||||
2. 创建迁移文件 seed 权限到 `permissions` 表
|
||||
3. 迁移中为需要该权限的角色添加 `role_permissions` 记录
|
||||
4. 前端路由声明对应 `permissions` 数组
|
||||
5. 更新本文档
|
||||
|
||||
### 7.2 关键迁移文件
|
||||
|
||||
| 迁移 | 说明 |
|
||||
|------|------|
|
||||
| `seed.rs` | 基础权限(auth/config/workflow/message/plugin) |
|
||||
| `m20260506_000125` | 创建 doctor/nurse/health_manager/operator 角色及初始权限 |
|
||||
| `m20260508_000131` | 权威修复:重新分配 doctor/nurse/operator 权限 |
|
||||
| `m20260510_000133` | 创建 patient 角色(data_scope=self,18 个 .list 权限) |
|
||||
| `m20260510_000137` | 媒体库/轮播图权限 + operator 补充 |
|
||||
| `m20260516_000147` | AI 对话权限(patient + admin) |
|
||||
| `m20260518_000149` | admin 全量权限修复 |
|
||||
| `m20260521_000164` | 菜单体系重组 |
|
||||
| `m20260522_000161` | patient 积分 manage 权限 |
|
||||
| `m20260522_000162` | patient 全量小程序权限(15 manage + 1 list + system.analytics.submit 注册) |
|
||||
|
||||
### 7.3 权限同步机制
|
||||
|
||||
系统启动时自动同步:模块通过 `ErpModule` trait 注册 `PermissionDescriptor`,`sync_module_permissions()` 将新权限码插入 `permissions` 表,admin 角色自动获得所有新权限。
|
||||
Reference in New Issue
Block a user