数据层可插拔(SQLite/MariaDB) + 后端 services 骨架
- database.ts 改为按 DB_TYPE 切换 SQLite / MariaDB(mysql2),sqlite3 按需动态导入 - index.ts 改为初始化数据库后再监听端口 - 新增 AuthService / HealthService / AnalysisService / GarminService - 新增 .gitignore、server/.env(忽略) 与 .env.example - README 增加数据库配置说明;package.json 增加 mysql2/@types
This commit is contained in:
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,27 +1,29 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
build/
|
# Env (contains secrets - never commit)
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
.DS_Store
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
~*
|
|
||||||
|
|
||||||
# Database
|
# Local SQLite data
|
||||||
|
data/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# API Keys and secrets
|
# Build output
|
||||||
.env.production.local
|
dist/
|
||||||
config/secrets.json
|
build/
|
||||||
|
|
||||||
# Build artifacts
|
# Logs
|
||||||
server/dist/
|
logs/
|
||||||
client/build/
|
*.log
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# WorkBuddy tool state (not project code)
|
||||||
|
.workbuddy/
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -75,6 +75,25 @@ JWT_SECRET=your_jwt_secret_here
|
|||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 数据库:SQLite / MariaDB 可插拔
|
||||||
|
|
||||||
|
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**:
|
||||||
|
|
||||||
|
- **SQLite(默认,本地开发)**:零配置,由 `DATABASE_PATH` 指定文件位置。
|
||||||
|
- **MariaDB(生产,运行在 NAS 上)**:经 `mysql2` 连接,优先走 socket(`MARIADB_SOCKET`),也可用 `MARIADB_HOST` + `MARIADB_PORT`。
|
||||||
|
|
||||||
|
生产使用 NAS 上的独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `server/.env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_TYPE=mariadb
|
||||||
|
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||||
|
MARIADB_USER=root
|
||||||
|
MARIADB_PASSWORD=your_nas_mariadb_root_password
|
||||||
|
MARIADB_DATABASE=garmin_health_lab
|
||||||
|
```
|
||||||
|
|
||||||
|
> 复制 `server/.env.example` 为 `server/.env` 并填入真实值;`.env` 已被 `.gitignore` 忽略,不会进入版本库。
|
||||||
|
|
||||||
### 启动开发服务器
|
### 启动开发服务器
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
21622
package-lock.json
generated
Normal file
21622
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,21 @@
|
|||||||
# Server Configuration
|
# App
|
||||||
PORT=5000
|
PORT=5000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
JWT_SECRET=generate_a_random_string
|
||||||
# Database
|
|
||||||
DATABASE_PATH=./data/health.db
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=your_jwt_secret_key_here_change_in_production
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# Garmin Configuration
|
# --- Database ---
|
||||||
# Note: Consider using OAuth instead of storing credentials
|
# Local/dev: SQLite (zero config)
|
||||||
GARMIN_CONNECT_USER=your_garmin_email@example.com
|
# DB_TYPE=sqlite
|
||||||
GARMIN_CONNECT_PASSWORD=your_password_here
|
# DATABASE_PATH=./data/health.db
|
||||||
|
#
|
||||||
|
# Production: MariaDB on the NAS
|
||||||
|
DB_TYPE=mariadb
|
||||||
|
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||||
|
MARIADB_USER=root
|
||||||
|
MARIADB_PASSWORD=your_nas_mariadb_root_password
|
||||||
|
MARIADB_DATABASE=garmin_health_lab
|
||||||
|
|
||||||
# Data Sync
|
# Garmin Connect
|
||||||
SYNC_INTERVAL_HOURS=1
|
GARMIN_CONNECT_USER=your_garmin_email
|
||||||
|
GARMIN_CONNECT_PASSWORD=your_garmin_password
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
|
"mysql2": "^3.6.0",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
@@ -21,8 +22,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/cors": "^2.8.13",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
"@types/jsonwebtoken": "^9.0.2",
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
|
"@types/sqlite3": "^3.1.8",
|
||||||
"typescript": "^5.1.3",
|
"typescript": "^5.1.3",
|
||||||
"tsx": "^3.12.7"
|
"tsx": "^3.12.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ app.use(cors({
|
|||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
initializeDatabase();
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/garmin', garminRoutes);
|
app.use('/api/garmin', garminRoutes);
|
||||||
@@ -37,6 +34,14 @@ app.get('/api/health/status', (req, res) => {
|
|||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// Initialize database, then start accepting requests
|
||||||
|
initializeDatabase()
|
||||||
|
.then(() => {
|
||||||
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[db] Failed to initialize database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
130
server/src/services/AnalysisService.ts
Normal file
130
server/src/services/AnalysisService.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { allAsync } from '../utils/database';
|
||||||
|
import { getSummary } from './HealthService';
|
||||||
|
|
||||||
|
const METRIC_COLUMNS: Record<string, string> = {
|
||||||
|
steps: 'steps',
|
||||||
|
heart_rate: 'heart_rate',
|
||||||
|
sleep_duration: 'sleep_duration',
|
||||||
|
sleep_quality: 'sleep_quality',
|
||||||
|
stress: 'stress',
|
||||||
|
calories_burned: 'calories_burned',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getTrends(metric: string, userId: string, startDate?: string, endDate?: string) {
|
||||||
|
const column = METRIC_COLUMNS[metric] || 'steps';
|
||||||
|
const params: any[] = [userId];
|
||||||
|
let sql = 'WHERE user_id = ?';
|
||||||
|
if (startDate) { sql += ' AND date >= ?'; params.push(startDate); }
|
||||||
|
if (endDate) { sql += ' AND date <= ?'; params.push(endDate); }
|
||||||
|
|
||||||
|
const rows = await allAsync(
|
||||||
|
`SELECT date, ${column} AS value FROM health_data ${sql}
|
||||||
|
AND ${column} IS NOT NULL ORDER BY date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => ({ date: r.date, value: r.value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
recommendation: string;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
basedOn: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecommendations(userId: string): Promise<Recommendation[]> {
|
||||||
|
// Look at the most recent 14 days of data.
|
||||||
|
const recent = await getSummary(userId);
|
||||||
|
const last14 = recent.slice(-14);
|
||||||
|
const recs: Recommendation[] = [];
|
||||||
|
|
||||||
|
if (last14.length === 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'no-data',
|
||||||
|
category: '数据',
|
||||||
|
recommendation: '暂无健康数据,请先同步你的 Garmin 设备数据。',
|
||||||
|
priority: 'low',
|
||||||
|
basedOn: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const avg = (key: string) =>
|
||||||
|
last14.reduce((sum, r) => sum + (r[key] ?? 0), 0) / last14.length;
|
||||||
|
|
||||||
|
const avgSteps = avg('steps');
|
||||||
|
const avgSleep = last14.filter((r) => r.sleep).reduce(
|
||||||
|
(s, r) => s + (r.sleep?.duration ?? 0),
|
||||||
|
0
|
||||||
|
) / Math.max(1, last14.filter((r) => r.sleep).length);
|
||||||
|
const avgStress = avg('stress');
|
||||||
|
const avgRestingHr = avg('heartRate');
|
||||||
|
const avgHrv = avg('heartRateVariability');
|
||||||
|
|
||||||
|
if (avgSteps > 0 && avgSteps < 8000) {
|
||||||
|
recs.push({
|
||||||
|
id: 'steps',
|
||||||
|
category: '运动',
|
||||||
|
recommendation: `近 ${last14.length} 天日均步数约 ${Math.round(avgSteps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。`,
|
||||||
|
priority: 'medium',
|
||||||
|
basedOn: ['steps'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgSleep > 0 && avgSleep < 7) {
|
||||||
|
recs.push({
|
||||||
|
id: 'sleep',
|
||||||
|
category: '睡眠',
|
||||||
|
recommendation: `日均睡眠约 ${avgSleep.toFixed(1)} 小时,偏少。建议固定就寝时间,目标 7-8 小时。`,
|
||||||
|
priority: 'high',
|
||||||
|
basedOn: ['sleep_duration'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgStress > 0 && avgStress > 50) {
|
||||||
|
recs.push({
|
||||||
|
id: 'stress',
|
||||||
|
category: '压力',
|
||||||
|
recommendation: `平均压力指数 ${Math.round(avgStress)} 偏高,建议安排放松活动(冥想/散步)。`,
|
||||||
|
priority: 'high',
|
||||||
|
basedOn: ['stress'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgRestingHr > 0 && avgRestingHr > 65) {
|
||||||
|
recs.push({
|
||||||
|
id: 'rhr',
|
||||||
|
category: '心肺',
|
||||||
|
recommendation: `静息心率约 ${Math.round(avgRestingHr)} bpm 偏高,规律有氧运动有助于改善心肺功能。`,
|
||||||
|
priority: 'medium',
|
||||||
|
basedOn: ['heart_rate'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgHrv > 0 && avgHrv < 40) {
|
||||||
|
recs.push({
|
||||||
|
id: 'hrv',
|
||||||
|
category: '恢复',
|
||||||
|
recommendation: `心率变异性(HRV)约 ${Math.round(avgHrv)} ms 偏低,注意恢复与休息,避免过度训练。`,
|
||||||
|
priority: 'low',
|
||||||
|
basedOn: ['heart_rate_variability'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recs.length === 0) {
|
||||||
|
recs.push({
|
||||||
|
id: 'good',
|
||||||
|
category: '状态',
|
||||||
|
recommendation: '近期各项指标良好,保持当前作息与运动习惯即可。',
|
||||||
|
priority: 'low',
|
||||||
|
basedOn: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority
|
||||||
|
const order = { high: 0, medium: 1, low: 2 } as const;
|
||||||
|
recs.sort((a, b) => order[a.priority] - order[b.priority]);
|
||||||
|
return recs;
|
||||||
|
}
|
||||||
89
server/src/services/AuthService.ts
Normal file
89
server/src/services/AuthService.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { runAsync, getAsync } from '../utils/database';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
|
||||||
|
const TOKEN_EXPIRY = '7d';
|
||||||
|
|
||||||
|
export class AuthError extends Error {
|
||||||
|
constructor(public code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPassword(password: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(`${salt}:${derived.toString('hex')}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const [salt, hash] = stored.split(':');
|
||||||
|
if (!salt || !hash) return resolve(false);
|
||||||
|
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(derived.toString('hex') === hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(userId: string): string {
|
||||||
|
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(input: {
|
||||||
|
email: string;
|
||||||
|
garminEmail: string;
|
||||||
|
garminPassword: string;
|
||||||
|
}) {
|
||||||
|
const existing = await getAsync('SELECT id FROM users WHERE email = ?', [input.email]);
|
||||||
|
if (existing) {
|
||||||
|
throw new AuthError('EMAIL_TAKEN', '该邮箱已注册');
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const garminPasswordHash = await hashPassword(input.garminPassword);
|
||||||
|
const token = signToken(id);
|
||||||
|
await runAsync(
|
||||||
|
`INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[id, input.email, input.garminEmail, garminPasswordHash, token]
|
||||||
|
);
|
||||||
|
return { id, email: input.email, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const user = await getAsync('SELECT * FROM users WHERE email = ?', [email]);
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||||
|
}
|
||||||
|
const ok = await verifyPassword(password, user.garmin_password_hash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||||
|
}
|
||||||
|
const token = signToken(user.id);
|
||||||
|
await runAsync('UPDATE users SET jwt_token = ? WHERE id = ?', [token, user.id]);
|
||||||
|
return { id: user.id, email: user.email, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(userId: string) {
|
||||||
|
await runAsync('UPDATE users SET jwt_token = NULL WHERE id = ?', [userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(userId: string) {
|
||||||
|
const user = await getAsync(
|
||||||
|
'SELECT id, email, garmin_email, created_at FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): { userId: string } {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
|
||||||
|
return { userId: payload.sub };
|
||||||
|
}
|
||||||
121
server/src/services/GarminService.ts
Normal file
121
server/src/services/GarminService.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { getAsync, runAsync } from '../utils/database';
|
||||||
|
import { upsertHealthDaily, insertActivity } from './HealthService';
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
recordsSynced: number;
|
||||||
|
message: string;
|
||||||
|
lastSyncTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncData(
|
||||||
|
userId: string,
|
||||||
|
creds: { garminEmail: string; garminPassword: string }
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await runAsync(
|
||||||
|
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||||
|
VALUES (?, 'syncing', ?, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE status='syncing', last_sync_time=?, records_synced=0`,
|
||||||
|
[userId, now, now]
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let GarminConnect: any;
|
||||||
|
try {
|
||||||
|
GarminConnect = require('garmin-connect');
|
||||||
|
} catch {
|
||||||
|
throw new Error('GARMIN_LIB_MISSING: 请先运行 `npm install garmin-connect` 以启用同步');
|
||||||
|
}
|
||||||
|
|
||||||
|
const Client = GarminConnect.default || GarminConnect.GarminConnect || GarminConnect;
|
||||||
|
const client = new Client({ username: creds.garminEmail, password: creds.garminPassword });
|
||||||
|
await client.login();
|
||||||
|
|
||||||
|
let recordsSynced = 0;
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
|
try {
|
||||||
|
const daily =
|
||||||
|
(await client.getUserDailySummary?.(dateStr)) ||
|
||||||
|
(await client.getDailySummary?.(dateStr)) ||
|
||||||
|
(await client.getStats?.(dateStr));
|
||||||
|
|
||||||
|
if (daily) {
|
||||||
|
const sleepSec = daily.sleep?.sleepingSeconds ?? daily.sleepingSeconds;
|
||||||
|
await upsertHealthDaily(userId, {
|
||||||
|
date: dateStr,
|
||||||
|
steps: daily.steps ?? null,
|
||||||
|
heartRate: daily.restingHeartRate ?? daily.averageHeartRate ?? null,
|
||||||
|
heartRateVariability: daily.hrv ?? daily.heartRateVariability ?? null,
|
||||||
|
sleepDuration: sleepSec ? Math.round(sleepSec / 3600 * 10) / 10 : null,
|
||||||
|
sleepQuality: daily.sleep?.sleepQuality ?? null,
|
||||||
|
stress: daily.stress?.average ?? daily.averageStress ?? null,
|
||||||
|
caloriesBurned: daily.calories?.total ?? daily.totalCalories ?? null,
|
||||||
|
});
|
||||||
|
recordsSynced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities =
|
||||||
|
(await client.getActivities?.(dateStr)) ||
|
||||||
|
(await client.getActivitiesByDate?.(dateStr)) ||
|
||||||
|
[];
|
||||||
|
for (const a of activities || []) {
|
||||||
|
const start = a.startTimeLocal || a.startTime;
|
||||||
|
const startMs = start ? new Date(start).getTime() : NaN;
|
||||||
|
await insertActivity(userId, {
|
||||||
|
activityType: a.activityType?.typeKey || a.type || 'unknown',
|
||||||
|
startTime: start,
|
||||||
|
endTime:
|
||||||
|
isNaN(startMs) || !a.duration ? start : new Date(startMs + (a.duration || 0) * 1000).toISOString(),
|
||||||
|
duration: a.duration ?? null,
|
||||||
|
distance: a.distance ?? null,
|
||||||
|
calories: a.calories ?? null,
|
||||||
|
heartRateAverage: a.averageHR ?? null,
|
||||||
|
heartRateMax: a.maxHR ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip a single bad day and continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAsync(
|
||||||
|
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||||
|
VALUES (?, 'idle', ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE status='idle', last_sync_time=?, records_synced=?`,
|
||||||
|
[userId, now, recordsSynced, now, recordsSynced]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
recordsSynced,
|
||||||
|
message: `同步完成,新增/更新 ${recordsSynced} 天数据`,
|
||||||
|
lastSyncTime: now,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || String(err);
|
||||||
|
await runAsync(
|
||||||
|
`INSERT INTO sync_status (user_id, status, last_sync_time, last_error, records_synced)
|
||||||
|
VALUES (?, 'error', ?, ?, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE status='error', last_sync_time=?, last_error=?, records_synced=0`,
|
||||||
|
[userId, now, message, now, message]
|
||||||
|
);
|
||||||
|
return { status: 'error', recordsSynced: 0, message, lastSyncTime: now };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSyncStatus(userId: string) {
|
||||||
|
const row = await getAsync('SELECT * FROM sync_status WHERE user_id = ?', [userId]);
|
||||||
|
if (!row) return { status: 'idle', lastSyncTime: null, recordsSynced: 0, lastError: null };
|
||||||
|
return {
|
||||||
|
status: row.status,
|
||||||
|
lastSyncTime: row.last_sync_time,
|
||||||
|
recordsSynced: row.records_synced,
|
||||||
|
lastError: row.last_error,
|
||||||
|
};
|
||||||
|
}
|
||||||
134
server/src/services/HealthService.ts
Normal file
134
server/src/services/HealthService.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { allAsync, runAsync } from '../utils/database';
|
||||||
|
|
||||||
|
interface DateRange {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeParams(userId: string, range?: DateRange): { sql: string; params: any[] } {
|
||||||
|
const params: any[] = [userId];
|
||||||
|
let sql = 'WHERE user_id = ?';
|
||||||
|
if (range?.startDate) {
|
||||||
|
sql += ' AND date >= ?';
|
||||||
|
params.push(range.startDate);
|
||||||
|
}
|
||||||
|
if (range?.endDate) {
|
||||||
|
sql += ' AND date <= ?';
|
||||||
|
params.push(range.endDate);
|
||||||
|
}
|
||||||
|
return { sql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSummary(userId: string, range?: DateRange) {
|
||||||
|
const { sql, params } = rangeParams(userId, range);
|
||||||
|
const rows = await allAsync(
|
||||||
|
`SELECT date, steps, heart_rate, heart_rate_variability,
|
||||||
|
sleep_duration, sleep_quality, stress, calories_burned
|
||||||
|
FROM health_data ${sql} ORDER BY date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
date: r.date,
|
||||||
|
steps: r.steps,
|
||||||
|
heartRate: r.heart_rate,
|
||||||
|
heartRateVariability: r.heart_rate_variability,
|
||||||
|
sleep: r.sleep_duration != null ? { duration: r.sleep_duration, quality: r.sleep_quality } : null,
|
||||||
|
stress: r.stress,
|
||||||
|
caloriesBurned: r.calories_burned,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSteps(userId: string, range?: DateRange) {
|
||||||
|
const { sql, params } = rangeParams(userId, range);
|
||||||
|
const rows = await allAsync(
|
||||||
|
`SELECT date, steps FROM health_data ${sql} AND steps IS NOT NULL ORDER BY date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => ({ date: r.date, steps: r.steps }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHeartRate(userId: string, range?: DateRange) {
|
||||||
|
const { sql, params } = rangeParams(userId, range);
|
||||||
|
const rows = await allAsync(
|
||||||
|
`SELECT date, heart_rate, heart_rate_variability FROM health_data ${sql}
|
||||||
|
AND heart_rate IS NOT NULL ORDER BY date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
date: r.date,
|
||||||
|
heartRate: r.heart_rate,
|
||||||
|
heartRateVariability: r.heart_rate_variability,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSleep(userId: string, range?: DateRange) {
|
||||||
|
const { sql, params } = rangeParams(userId, range);
|
||||||
|
const rows = await allAsync(
|
||||||
|
`SELECT date, sleep_duration, sleep_quality FROM health_data ${sql}
|
||||||
|
AND sleep_duration IS NOT NULL ORDER BY date ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
date: r.date,
|
||||||
|
duration: r.sleep_duration,
|
||||||
|
quality: r.sleep_quality,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivities(userId: string, range?: DateRange) {
|
||||||
|
const { sql, params } = rangeParams(userId, range);
|
||||||
|
const rows = await allAsync(
|
||||||
|
`SELECT id, activity_type, start_time, end_time, duration, distance,
|
||||||
|
calories, heart_rate_average, heart_rate_max
|
||||||
|
FROM activities ${sql} ORDER BY start_time DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by GarminService to persist daily health records (upsert by user+date).
|
||||||
|
export async function upsertHealthDaily(userId: string, record: any) {
|
||||||
|
const id = `${userId}-${record.date}`;
|
||||||
|
await runAsync(
|
||||||
|
`INSERT INTO health_data
|
||||||
|
(id, user_id, date, steps, heart_rate, heart_rate_variability,
|
||||||
|
blood_pressure_systolic, blood_pressure_diastolic, sleep_duration,
|
||||||
|
sleep_quality, stress, calories_burned)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
steps = VALUES(steps),
|
||||||
|
heart_rate = VALUES(heart_rate),
|
||||||
|
heart_rate_variability = VALUES(heart_rate_variability),
|
||||||
|
blood_pressure_systolic = VALUES(blood_pressure_systolic),
|
||||||
|
blood_pressure_diastolic = VALUES(blood_pressure_diastolic),
|
||||||
|
sleep_duration = VALUES(sleep_duration),
|
||||||
|
sleep_quality = VALUES(sleep_quality),
|
||||||
|
stress = VALUES(stress),
|
||||||
|
calories_burned = VALUES(calories_burned),
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[
|
||||||
|
id, userId, record.date, record.steps ?? null, record.heartRate ?? null,
|
||||||
|
record.heartRateVariability ?? null, record.bloodPressureSystolic ?? null,
|
||||||
|
record.bloodPressureDiastolic ?? null, record.sleepDuration ?? null,
|
||||||
|
record.sleepQuality ?? null, record.stress ?? null, record.caloriesBurned ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertActivity(userId: string, activity: any) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await runAsync(
|
||||||
|
`INSERT INTO activities
|
||||||
|
(id, user_id, activity_type, start_time, end_time, duration, distance,
|
||||||
|
calories, heart_rate_average, heart_rate_max)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id, userId, activity.activityType, activity.startTime, activity.endTime,
|
||||||
|
activity.duration ?? null, activity.distance ?? null, activity.calories ?? null,
|
||||||
|
activity.heartRateAverage ?? null, activity.heartRateMax ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
import sqlite3 from 'sqlite3';
|
import mysql from 'mysql2/promise';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
const dbPath = process.env.DATABASE_PATH || './data/health.db';
|
const DB_TYPE = (process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||||
|
|
||||||
// Ensure data directory exists
|
// ---------------------------------------------------------------------------
|
||||||
const dataDir = path.dirname(dbPath);
|
// SQLite (local / development)
|
||||||
if (!fs.existsSync(dataDir)) {
|
// Loaded lazily so the MariaDB build never depends on the native sqlite3 module.
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
// ---------------------------------------------------------------------------
|
||||||
}
|
let sqliteDb: any = null;
|
||||||
|
|
||||||
export const db = new sqlite3.Database(dbPath);
|
const SQLITE_SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
export function initializeDatabase() {
|
|
||||||
db.serialize(() => {
|
|
||||||
// Users table
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE NOT NULL,
|
||||||
garmin_email TEXT NOT NULL,
|
garmin_email TEXT NOT NULL,
|
||||||
@@ -24,12 +19,9 @@ export function initializeDatabase() {
|
|||||||
jwt_token TEXT,
|
jwt_token TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
);
|
||||||
`);
|
|
||||||
|
|
||||||
// Health data table
|
CREATE TABLE IF NOT EXISTS health_data (
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS health_data (
|
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
@@ -46,12 +38,9 @@ export function initializeDatabase() {
|
|||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
UNIQUE(user_id, date)
|
UNIQUE(user_id, date)
|
||||||
)
|
);
|
||||||
`);
|
|
||||||
|
|
||||||
// Activities table
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS activities (
|
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
activity_type TEXT NOT NULL,
|
activity_type TEXT NOT NULL,
|
||||||
@@ -64,12 +53,9 @@ export function initializeDatabase() {
|
|||||||
heart_rate_max INTEGER,
|
heart_rate_max INTEGER,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
)
|
);
|
||||||
`);
|
|
||||||
|
|
||||||
// Sync status table
|
CREATE TABLE IF NOT EXISTS sync_status (
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS sync_status (
|
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
last_sync_time DATETIME,
|
last_sync_time DATETIME,
|
||||||
status TEXT DEFAULT 'idle',
|
status TEXT DEFAULT 'idle',
|
||||||
@@ -77,34 +63,162 @@ export function initializeDatabase() {
|
|||||||
records_synced INTEGER DEFAULT 0,
|
records_synced INTEGER DEFAULT 0,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
)
|
);
|
||||||
`);
|
`;
|
||||||
|
|
||||||
console.log('Database initialized successfully');
|
// ---------------------------------------------------------------------------
|
||||||
});
|
// MariaDB (production, runs on the NAS)
|
||||||
|
// NOTE: TEXT cannot be a PRIMARY KEY in MariaDB, so ids use VARCHAR(64).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let mariadbPool: mysql.Pool | null = null;
|
||||||
|
|
||||||
|
const MARIADB_SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
garmin_email VARCHAR(255) NOT NULL,
|
||||||
|
garmin_password_hash TEXT NOT NULL,
|
||||||
|
jwt_token TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS health_data (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
steps INT,
|
||||||
|
heart_rate INT,
|
||||||
|
heart_rate_variability DOUBLE,
|
||||||
|
blood_pressure_systolic INT,
|
||||||
|
blood_pressure_diastolic INT,
|
||||||
|
sleep_duration INT,
|
||||||
|
sleep_quality DOUBLE,
|
||||||
|
stress INT,
|
||||||
|
calories_burned DOUBLE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
activity_type VARCHAR(255) NOT NULL,
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME NOT NULL,
|
||||||
|
duration INT,
|
||||||
|
distance DOUBLE,
|
||||||
|
calories DOUBLE,
|
||||||
|
heart_rate_average INT,
|
||||||
|
heart_rate_max INT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_status (
|
||||||
|
user_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
last_sync_time DATETIME,
|
||||||
|
status VARCHAR(32) DEFAULT 'idle',
|
||||||
|
last_error TEXT,
|
||||||
|
records_synced INT DEFAULT 0,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function getMariadbConfig(): mysql.PoolOptions {
|
||||||
|
const config: mysql.PoolOptions = {
|
||||||
|
user: process.env.MARIADB_USER || 'root',
|
||||||
|
password: process.env.MARIADB_PASSWORD || '',
|
||||||
|
database: process.env.MARIADB_DATABASE || 'garmin_health_lab',
|
||||||
|
connectionLimit: 10,
|
||||||
|
waitForConnections: true,
|
||||||
|
};
|
||||||
|
if (process.env.MARIADB_SOCKET) {
|
||||||
|
config.socketPath = process.env.MARIADB_SOCKET;
|
||||||
|
} else {
|
||||||
|
config.host = process.env.MARIADB_HOST || '127.0.0.1';
|
||||||
|
config.port = process.env.MARIADB_PORT ? Number(process.env.MARIADB_PORT) : 3306;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAsync(sql: string, params: any[] = []): Promise<any> {
|
export async function initializeDatabase(): Promise<void> {
|
||||||
|
if (DB_TYPE === 'mariadb') {
|
||||||
|
mariadbPool = mysql.createPool(getMariadbConfig());
|
||||||
|
const conn = await mariadbPool.getConnection();
|
||||||
|
try {
|
||||||
|
const statements = MARIADB_SCHEMA
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await conn.query(stmt);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
console.log('[db] MariaDB database initialized successfully');
|
||||||
|
} else {
|
||||||
|
const sqlitePath = process.env.DATABASE_PATH || './data/health.db';
|
||||||
|
const dataDir = path.dirname(sqlitePath);
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const sqlite3 = await import('sqlite3');
|
||||||
|
const SqliteDb = (sqlite3 as any).default?.Database || (sqlite3 as any).Database;
|
||||||
|
sqliteDb = new SqliteDb(sqlitePath);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sqliteDb.serialize(() => {
|
||||||
|
sqliteDb.exec(SQLITE_SCHEMA, (err: Error | null) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log('[db] SQLite database initialized successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified query helpers — same signatures for both backends.
|
||||||
|
// `?` placeholders are supported by both sqlite3 and mysql2.
|
||||||
|
|
||||||
|
export async function runAsync(sql: string, params: any[] = []): Promise<any> {
|
||||||
|
if (DB_TYPE === 'mariadb') {
|
||||||
|
const [result] = await mariadbPool!.execute(sql, params);
|
||||||
|
const r = result as any;
|
||||||
|
return { id: r.insertId, changes: r.affectedRows };
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.run(sql, params, function(err) {
|
sqliteDb.run(sql, params, function (this: any, err: Error | null) {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve({ id: this.lastID, changes: this.changes });
|
else resolve({ id: this.lastID, changes: this.changes });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAsync(sql: string, params: any[] = []): Promise<any> {
|
export async function getAsync(sql: string, params: any[] = []): Promise<any> {
|
||||||
|
if (DB_TYPE === 'mariadb') {
|
||||||
|
const [rows] = await mariadbPool!.execute(sql, params);
|
||||||
|
return Array.isArray(rows) ? (rows as any[])[0] : undefined;
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.get(sql, params, (err, row) => {
|
sqliteDb.get(sql, params, (err: Error | null, row: any) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(row);
|
else resolve(row);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allAsync(sql: string, params: any[] = []): Promise<any[]> {
|
export async function allAsync(sql: string, params: any[] = []): Promise<any[]> {
|
||||||
|
if (DB_TYPE === 'mariadb') {
|
||||||
|
const [rows] = await mariadbPool!.execute(sql, params);
|
||||||
|
return (rows as any[]) || [];
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.all(sql, params, (err, rows) => {
|
sqliteDb.all(sql, params, (err: Error | null, rows: any[]) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(rows || []);
|
else resolve(rows || []);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user