Explorar el Código

feat(analytics): add Python data layer (synthetic generator + analysis CLI) and root README

17846405080 hace 1 mes
padre
commit
69c0429c6d
Se han modificado 4 ficheros con 528 adiciones y 4 borrados
  1. 5 0
      .gitignore
  2. 118 0
      README.md
  3. 398 0
      campus_health_app/analytics/analyze.py
  4. 7 4
      requirements.txt

+ 5 - 0
.gitignore

@@ -38,6 +38,11 @@ Thumbs.db
 coverage/
 .nyc_output/
 
+# ===== Python analytics output =====
+campus_health_app/analytics/output/
+__pycache__/
+*.pyc
+
 # ===== Misc =====
 .cache/
 tmp/

+ 118 - 0
README.md

@@ -0,0 +1,118 @@
+# 数智健调系统 · Campus Health
+
+> 面向校园场景的健康管理 Web 应用,以**体重管理**为核心模块,覆盖记录、目标、标签、异常检测、可视化全链路。
+> 技术栈:Angular 20 + Node.js/Express/TypeORM + MySQL 8 + Python(数据洞察)。
+
+![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue)
+![Angular](https://img.shields.io/badge/Angular-20-red)
+![Node](https://img.shields.io/badge/Node-18-green)
+![MySQL](https://img.shields.io/badge/MySQL-8.0-orange)
+![Python](https://img.shields.io/badge/Python-3.14-yellow)
+
+---
+
+## 项目亮点
+
+- **完整分层后端**:Controller / Service / DTO / Middleware 四层分离,统一响应格式、全局错误处理、Helmet 安全头、限流、gzip、Winston 日志、优雅关闭。
+- **数据模型设计**:6 张核心表(用户 / 体重记录 / 目标 / 标签 / 关联表 / 异常日志),含软删除、外键约束、CHECK 约束、复合索引,并配套用户体重统计视图。
+- **可视化前端**:Angular Standalone 组件 + ECharts,体重趋势 / 周变化柱状图 / 体脂散点 / 目标进度环,移动端优先。
+- **数据洞察层**(`analytics/`):独立的 Python 分析脚本,基于 pandas + SQL 完成:
+  - 合成数据生成器(180 天减重轨迹 + 标签事件 + 异常点,可复现)
+  - 空腹 vs 餐后称重偏差分析
+  - 标签事件前后减重速度对比
+  - 自动异常扫描(周变化 >2kg、体重降但体脂升)
+- **AI 集成**:DeepSeek 对话服务,封装为共享 Modal 组件。
+
+## 目录结构
+
+```
+zrd-nb666/
+├── campus_health_app/
+│   ├── backend/            # Express + TypeORM 后端
+│   │   ├── database/       # init.sql / schema.sql / seed.sql
+│   │   └── src/
+│   │       ├── config/        # 数据库配置
+│   │       ├── controllers/   # 路由处理
+│   │       ├── services/      # 业务逻辑(统计/目标/标签)
+│   │       ├── entities/      # TypeORM 实体
+│   │       ├── dto/          # 请求校验
+│   │       └── middlewares/   # 鉴权 / 错误 / 日志
+│   ├── frontend/campus-health-app/   # Angular 20 前端
+│   │   └── src/app/
+│   │       ├── modules/       # weight / diet / exercise / dashboard / monitoring / ...
+│   │       ├── shared/       # 通用组件(Modal / AI Chat / CalendarGrid)
+│   │       └── services/     # HTTP 与 DeepSeek 服务
+│   ├── analytics/          # ⭐ Python 数据分析
+│   │   └── analyze.py       # generate / report 两个子命令
+│   └── docs/               # 架构图、ER 图、WBS、部署图(HTML)
+└── requirements.txt        # Python 依赖
+```
+
+## 本地启动
+
+### 1. 数据库
+
+```bash
+# 启动 MySQL 后
+mysql -u root -p < campus_health_app/backend/database/init.sql
+mysql -u root -p campus_health < campus_health_app/backend/database/schema.sql
+```
+
+### 2. 后端
+
+```bash
+cd campus_health_app/backend
+npm install
+cp .env.example .env   # 填入 DB_PASSWORD
+npm run dev            # http://localhost:3000/api
+```
+
+### 3. 前端
+
+```bash
+cd campus_health_app/frontend/campus-health-app
+npm install
+npm start              # http://localhost:4200
+```
+
+### 4. 数据分析(Python)
+
+```bash
+pip install -r requirements.txt
+
+# 让脚本能连上 MySQL
+$env:DB_PASSWORD = "你的MySQL密码"   # PowerShell
+
+# 生成 180 天合成数据
+python campus_health_app/analytics/analyze.py generate
+
+# 跑分析报告(终端表格 + output/weight_trend.png)
+python campus_health_app/analytics/analyze.py report
+```
+
+## 数据模型
+
+```
+users 1───n weight_records n───n tags
+              │
+              ├── 1 weight_goals  (每用户当前目标)
+              └── n anomaly_logs   (异常事件记录)
+```
+
+完整 ER 图见 [`docs/diagrams/report-database-er-new.html`](campus_health_app/docs/diagrams/report-database-er-new.html)。
+
+## API 一览
+
+| Method | Path | 说明 |
+|---|---|---|
+| GET | `/api/weight/records` | 体重记录列表(按日期/条件/标签筛选 + 分页) |
+| POST | `/api/weight/records` | 新增记录 |
+| PUT | `/api/weight/records/:id` | 更新记录 |
+| DELETE | `/api/weight/records/:id` | 软删除 |
+| GET/POST/PUT | `/api/weight/goal` | 目标管理 |
+| GET/POST | `/api/weight/tags` | 标签 |
+| GET | `/api/weight/stats` | 统计汇总(当前体重、周均变化、目标 ETA) |
+
+## License
+
+MIT

+ 398 - 0
campus_health_app/analytics/analyze.py

@@ -0,0 +1,398 @@
+"""
+体重数据分析 CLI
+=================
+
+两个子命令:
+  python analyze.py generate   生成 180 天合成数据(synthetic-user-001)写入 MySQL
+  python analyze.py report     从库里读数据,输出终端报告 + 一张趋势 PNG
+
+设计原则:
+- 合成数据有真实的减重轨迹 + 测量噪声 + 标签事件 + 若干异常点,
+  方便直接演示"数据洞察",不需要真实用户数据。
+- 所有 SQL 都写出来,方便面试官 review。
+"""
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from uuid import uuid4
+
+import matplotlib
+
+matplotlib.use("Agg")  # 无 GUI 环境也能出图
+import matplotlib.pyplot as plt
+
+# Windows 中文字体,避免图里中文变方框
+plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
+plt.rcParams["axes.unicode_minus"] = False
+import numpy as np
+import pandas as pd
+import pymysql
+import typer
+from rich.console import Console
+from rich.table import Table
+
+app = typer.Typer(help="体重数据合成与分析 CLI", no_args_is_help=True)
+console = Console()
+
+# ---------------------------------------------------------------------------
+# 数据库连接配置(与 backend/.env 保持一致)
+# ---------------------------------------------------------------------------
+import os
+
+DB_CONFIG = dict(
+    host=os.getenv("DB_HOST", "localhost"),
+    port=int(os.getenv("DB_PORT", "3306")),
+    user=os.getenv("DB_USERNAME", "analytics"),
+    password=os.getenv("DB_PASSWORD", "123456"),
+    database=os.getenv("DB_DATABASE", "campus_health"),
+    charset="utf8mb4",
+    autocommit=False,
+)
+
+SYNTH_USER_ID = "synthetic-user-001"
+SYNTH_USER_PHONE = "13900000001"
+SYNTH_USER_NICKNAME = "合成用户·小A"
+
+OUTPUT_DIR = Path(__file__).parent / "output"
+
+
+def get_conn(password: str | None = None) -> pymysql.Connection:
+    cfg = dict(DB_CONFIG)
+    if password:
+        cfg["password"] = password
+    try:
+        return pymysql.connect(**cfg)
+    except pymysql.err.OperationalError as e:
+        if e.args[0] == 1045 and not password:
+            # 密码错误或未提供,交互式问一次
+            pwd = typer.prompt("MySQL 密码", hide_input=True)
+            cfg["password"] = pwd
+            return pymysql.connect(**cfg)
+        console.print(f"[red]数据库连接失败:{e}[/red]")
+        console.print(
+            "请确认:1) MySQL 服务已启动  2) 已执行 backend/database/init.sql 和 schema.sql"
+        )
+        sys.exit(1)
+
+
+# ---------------------------------------------------------------------------
+# 数据生成器
+# ---------------------------------------------------------------------------
+@app.command()
+def generate(
+    days: int = typer.Option(180, help="生成多少天的数据"),
+    start_weight: float = typer.Option(75.0, help="初始体重 kg"),
+    target_weight: float = typer.Option(66.0, help="目标体重 kg"),
+    seed: int = typer.Option(42, help="随机种子,保证可复现"),
+):
+    """生成 180 天合成体重数据并写入 campus_health.weight_records"""
+    rng = np.random.default_rng(seed)
+    end_date = date.today()
+    start_date = end_date - timedelta(days=days - 1)
+
+    conn = get_conn()
+    with conn.cursor() as cur:
+        # 幂等:先清掉这个用户的旧数据
+        cur.execute("DELETE FROM weight_record_tags WHERE weight_record_id IN (SELECT id FROM weight_records WHERE user_id=%s)", (SYNTH_USER_ID,))
+        cur.execute("DELETE FROM weight_records WHERE user_id=%s", (SYNTH_USER_ID,))
+        cur.execute("DELETE FROM weight_goals WHERE user_id=%s", (SYNTH_USER_ID,))
+        cur.execute("DELETE FROM anomaly_logs WHERE user_id=%s", (SYNTH_USER_ID,))
+        cur.execute(
+            "INSERT INTO users (id, phone, nickname, grade) VALUES (%s,%s,%s,%s) "
+            "ON DUPLICATE KEY UPDATE nickname=VALUES(nickname)",
+            (SYNTH_USER_ID, SYNTH_USER_PHONE, SYNTH_USER_NICKNAME, "大三"),
+        )
+
+        # ---- 目标 ----
+        cur.execute(
+            "INSERT INTO weight_goals (id,user_id,target_weight,target_body_fat,target_date,"
+            "start_weight,start_body_fat,start_date,weekly_target,status) "
+            "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'active')",
+            (
+                str(uuid4()), SYNTH_USER_ID, target_weight, 21.0, end_date + timedelta(days=30),
+                start_weight, 28.0, start_date, 0.5,
+            ),
+        )
+
+        # ---- 标签事件(相对天数偏移)----
+        tag_events = {
+            0: "开始运动",
+            30: "饮食改变",
+            60: "生病",
+            90: "假期",
+            120: "目标调整",
+            150: "压力期",
+        }
+
+        # 取系统标签 ID
+        cur.execute("SELECT id, tag_name FROM tags WHERE user_id IS NULL")
+        tag_id_by_name = {name: tid for tid, name in cur.fetchall()}
+
+        # ---- 逐日生成 ----
+        records = []  # (record_id, date, weight, body_fat, muscle, condition, notes)
+        total_drop = start_weight - target_weight
+        for i in range(days):
+            d = start_date + timedelta(days=i)
+            # 周末 20% 概率不称
+            if d.weekday() >= 5 and rng.random() < 0.35:
+                continue
+
+            # 基线减重曲线:线性,但在标签事件点有折点
+            progress = i / days
+            baseline = start_weight - total_drop * progress
+
+            # 事件扰动
+            if 60 <= i < 67:           # 生病:急性脱水,体重骤降但体脂反升
+                baseline -= 1.8
+            elif 90 <= i < 98:         # 假期:饮食放纵,反弹
+                baseline += 1.5
+            elif 120 <= i < 140:       # 压力期:皮质醇高,平台
+                baseline += 0.4
+
+            # 测量噪声
+            noise = rng.normal(0, 0.25)
+            # 测量条件:80% 早晨空腹,20% 餐后
+            if rng.random() < 0.8:
+                condition = "fasting"
+                time_str = "07:45:00"
+                condition_offset = 0.0
+            else:
+                condition = "after_meal"
+                time_str = "12:30:00"
+                condition_offset = rng.uniform(0.8, 1.4)  # 餐后称重偏高
+
+            weight = round(baseline + noise + condition_offset, 2)
+            # 体脂:跟随体重下降,但生病周会反向
+            bf_base = 28.0 - 7.0 * progress
+            if 60 <= i < 67:
+                bf_base += 1.5
+            body_fat = round(np.clip(bf_base + rng.normal(0, 0.2), 5, 60), 2)
+            # 肌肉量:缓慢上升
+            muscle = round(26.0 + 1.2 * progress + rng.normal(0, 0.15), 2)
+
+            notes = None
+            if i in tag_events:
+                notes = f"事件:{tag_events[i]}"
+
+            records.append((str(uuid4()), d, time_str, weight, body_fat, muscle, condition, notes))
+
+        cur.executemany(
+            "INSERT INTO weight_records (id,user_id,record_date,measurement_time,weight,body_fat,muscle_mass,measurement_condition,notes) "
+            "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)",
+            [(r[0], SYNTH_USER_ID, r[1], r[2], r[3], r[4], r[5], r[6], r[7]) for r in records],
+        )
+
+        # 关联标签到事件当天的记录
+        for offset, tag_name in tag_events.items():
+            d = start_date + timedelta(days=offset)
+            cur.execute(
+                "SELECT id FROM weight_records WHERE user_id=%s AND record_date=%s LIMIT 1",
+                (SYNTH_USER_ID, d),
+            )
+            row = cur.fetchone()
+            if row and tag_name in tag_id_by_name:
+                cur.execute(
+                    "INSERT INTO weight_record_tags (id,weight_record_id,tag_id) VALUES (%s,%s,%s) "
+                    "ON DUPLICATE KEY UPDATE id=id",
+                    (str(uuid4()), row[0], tag_id_by_name[tag_name]),
+                )
+
+        # 埋一个 anomaly_log 示例
+        cur.execute(
+            "INSERT INTO anomaly_logs (id,user_id,anomaly_type,severity,message,detected_date) "
+            "VALUES (%s,%s,'rapid_change','warning','近7天体重骤降2.1kg(脱水/生病期),请结合体脂与症状判断',%s)",
+            (str(uuid4()), SYNTH_USER_ID, start_date + timedelta(days=63)),
+        )
+
+    conn.commit()
+    conn.close()
+    console.print(f"[green]✓ 已写入 {len(records)} 条记录,{len(tag_events)} 个标签事件[/green]")
+    console.print(f"  日期范围:{start_date} → {end_date}")
+    console.print(f"  体重区间:{start_weight}kg → {target_weight}kg(线性减重曲线 + 事件扰动)")
+
+
+# ---------------------------------------------------------------------------
+# 分析报告
+# ---------------------------------------------------------------------------
+def load_data() -> pd.DataFrame:
+    sql = """
+        SELECT wr.record_date, wr.weight, wr.body_fat, wr.muscle_mass,
+               wr.measurement_condition, wr.notes,
+               (SELECT GROUP_CONCAT(t.tag_name)
+                  FROM weight_record_tags wrt JOIN tags t ON t.id = wrt.tag_id
+                 WHERE wrt.weight_record_id = wr.id) AS tags
+        FROM weight_records wr
+        WHERE wr.user_id = %s AND wr.deleted_at IS NULL
+        ORDER BY wr.record_date
+    """
+    conn = get_conn()
+    df = pd.read_sql(sql, conn, params=[SYNTH_USER_ID])
+    conn.close()
+    df["record_date"] = pd.to_datetime(df["record_date"])
+    return df
+
+
+def report_overview(df: pd.DataFrame) -> None:
+    table = Table(title="总体进度", show_header=True, header_style="bold cyan")
+    table.add_column("指标")
+    table.add_column("数值", justify="right")
+    current = df.iloc[-1]
+    initial = df.iloc[0]
+    total_change = current["weight"] - initial["weight"]
+    days = (df["record_date"].iloc[-1] - df["record_date"].iloc[0]).days
+    weekly_avg = total_change / days * 7
+
+    # 最近 4 周趋势
+    recent = df[df["record_date"] >= df["record_date"].max() - pd.Timedelta(days=28)]
+    recent_weekly = (recent["weight"].iloc[-1] - recent["weight"].iloc[0]) / 4
+
+    table.add_row("记录天数", str(len(df)))
+    table.add_row("起始体重", f"{initial['weight']:.2f} kg")
+    table.add_row("当前体重", f"{current['weight']:.2f} kg")
+    table.add_row("累计变化", f"{total_change:+.2f} kg")
+    table.add_row("周均变化(全程)", f"{weekly_avg:+.3f} kg/周")
+    table.add_row("周均变化(近4周)", f"{recent_weekly:+.3f} kg/周")
+    table.add_row("体脂变化", f"{current['body_fat'] - initial['body_fat']:+.2f} %")
+    console.print(table)
+
+
+def report_condition_bias(df: pd.DataFrame) -> None:
+    """空腹 vs 餐后的称重偏差——这是个真实且有价值的洞察"""
+    fasting = df[df["measurement_condition"] == "fasting"]["weight"]
+    after = df[df["measurement_condition"] == "after_meal"]["weight"]
+    if len(fasting) < 3 or len(after) < 3:
+        console.print("[yellow]样本不足,跳过测量偏差分析[/yellow]")
+        return
+
+    table = Table(title="测量条件偏差(餐后 vs 空腹)", show_header=True, header_style="bold magenta")
+    table.add_column("条件")
+    table.add_column("样本数", justify="right")
+    table.add_column("均值 (kg)", justify="right")
+    table.add_column("标准差", justify="right")
+    table.add_row("空腹", str(len(fasting)), f"{fasting.mean():.2f}", f"{fasting.std():.2f}")
+    table.add_row("餐后", str(len(after)), f"{after.mean():.2f}", f"{after.std():.2f}")
+    table.add_row("差值", "—", f"{after.mean() - fasting.mean():+.2f}", "—")
+    console.print(table)
+    console.print(
+        f"[dim]→ 餐后称重比空腹平均高 {after.mean()-fasting.mean():.2f} kg,"
+        "跨条件比较时必须先按条件分层,否则会把'刚吃完饭'误读成'长胖了'。[/dim]"
+    )
+
+
+def report_tag_impact(df: pd.DataFrame) -> None:
+    """每个标签事件前后 7 天的周减重速度对比"""
+    events = df[df["tags"].notna() & (df["tags"] != "")]
+    if events.empty:
+        return
+
+    table = Table(title="标签事件影响(事件日 ±7 天的周减重速度)", show_header=True, header_style="bold green")
+    table.add_column("标签")
+    table.add_column("事件日")
+    table.add_column("前7天周速 (kg/周)")
+    table.add_column("后7天周速 (kg/周)")
+    table.add_column("变化")
+
+    for _, row in events.iterrows():
+        tags = str(row["tags"]).split(",")
+        for tag in tags:
+            event_date = row["record_date"]
+            pre = df[(df["record_date"] < event_date) & (df["record_date"] >= event_date - pd.Timedelta(days=7))]
+            post = df[(df["record_date"] > event_date) & (df["record_date"] <= event_date + pd.Timedelta(days=7))]
+            if len(pre) < 2 or len(post) < 2:
+                continue
+            pre_speed = (pre["weight"].iloc[-1] - pre["weight"].iloc[0]) / max(len(pre) - 1, 1) * 7
+            post_speed = (post["weight"].iloc[-1] - post["weight"].iloc[0]) / max(len(post) - 1, 1) * 7
+            delta = post_speed - pre_speed
+            table.add_row(
+                tag,
+                event_date.strftime("%Y-%m-%d"),
+                f"{pre_speed:+.2f}",
+                f"{post_speed:+.2f}",
+                f"{delta:+.2f}",
+            )
+    console.print(table)
+
+
+def report_anomaly(df: pd.DataFrame) -> None:
+    """简单的异常检测:周变化 > 2kg 或 体重降但体脂升"""
+    df = df.sort_values("record_date").reset_index(drop=True)
+    table = Table(title="自动异常扫描", show_header=True, header_style="bold red")
+    table.add_column("日期")
+    table.add_column("类型")
+    table.add_column("说明")
+
+    for i in range(7, len(df)):
+        week_ago = df.iloc[i - 7]
+        now = df.iloc[i]
+        dw = now["weight"] - week_ago["weight"]
+        dbf = now["body_fat"] - week_ago["body_fat"]
+        if abs(dw) > 2.0:
+            table.add_row(
+                now["record_date"].strftime("%Y-%m-%d"),
+                "rapid_change",
+                f"7天体重变化 {dw:+.2f} kg(阈值 ±2.0)",
+            )
+        if dw < -0.5 and dbf > 0.5:
+            table.add_row(
+                now["record_date"].strftime("%Y-%m-%d"),
+                "body_fat_anomaly",
+                f"体重 {dw:+.2f} kg 但体脂 {dbf:+.2f}%——疑似脱水而非减脂",
+            )
+    if table.row_count == 0:
+        console.print("[green]未发现异常[/green]")
+    else:
+        console.print(table)
+
+
+def draw_chart(df: pd.DataFrame) -> Path:
+    OUTPUT_DIR.mkdir(exist_ok=True)
+    fig, ax = plt.subplots(figsize=(11, 5.5))
+    fasting = df[df["measurement_condition"] == "fasting"]
+    after = df[df["measurement_condition"] == "after_meal"]
+    ax.scatter(after["record_date"], after["weight"], s=18, alpha=0.5, color="#f59e0b", label="餐后")
+    ax.plot(fasting["record_date"], fasting["weight"], color="#2563eb", linewidth=1.5, label="空腹趋势")
+
+    # 标签事件竖线
+    events = df[df["tags"].notna() & (df["tags"] != "")]
+    for _, row in events.iterrows():
+        ax.axvline(row["record_date"], color="#9ca3af", linestyle="--", alpha=0.6)
+        ax.text(row["record_date"], ax.get_ylim()[1], row["tags"].split(",")[0], rotation=90,
+                fontsize=8, va="top", ha="right", color="#374151")
+
+    ax.set_title("Synthetic User — Weight Trajectory")
+    ax.set_xlabel("Date")
+    ax.set_ylabel("Weight (kg)")
+    ax.legend(loc="upper right")
+    ax.grid(alpha=0.3)
+    fig.tight_layout()
+    out = OUTPUT_DIR / "weight_trend.png"
+    fig.savefig(out, dpi=130)
+    plt.close(fig)
+    return out
+
+
+@app.command()
+def report():
+    """从库里读数据,输出分析报告 + 趋势图"""
+    df = load_data()
+    if df.empty:
+        console.print("[red]没有数据,请先运行:python analyze.py generate[/red]")
+        raise typer.Exit(1)
+
+    console.print(f"\n[bold]数据窗口:[/bold]{df['record_date'].iloc[0].date()} → {df['record_date'].iloc[-1].date()},共 {len(df)} 条\n")
+    report_overview(df)
+    console.print()
+    report_condition_bias(df)
+    console.print()
+    report_tag_impact(df)
+    console.print()
+    report_anomaly(df)
+    chart_path = draw_chart(df)
+    console.print(f"\n[green]✓ 趋势图已保存:{chart_path}[/green]")
+
+
+if __name__ == "__main__":
+    app()

+ 7 - 4
requirements.txt

@@ -1,4 +1,7 @@
-argparse==1.4.0
-click==8.3.0
-typer==0.19.2
-rich==14.1.0
+# Python 分析层依赖(campus_health_app/analytics/)
+pandas>=2.0
+pymysql>=1.1
+matplotlib>=3.8
+numpy>=1.26
+typer>=0.9
+rich>=13.0