先说结论:这类标题之所以让你觉得“乱了”“不像 24 岁该看的内容”,往往不是年龄问题,而是信息罗列得太密,护理项目之间没有任何数据关系,状态全靠“感觉”。如果把“经络养护、细胞检测、美甲、提拉面膜”这些零散护理统一放进一个可记录、可追踪、可复盘的系统里,你会发现 24 岁的皮肤和身体状态,其实是可以被量化管理的。
这篇文章不是教你做美容,而是带你从零搭建一个“个人护理记录管理系统”。我会用 Python Flask + SQLite 实现一个轻量级 Web 应用,把护理项目、检测结果、身体感受全部结构化,最终形成一个能长期追踪的个人护理数据库。无论你是后端初学者,还是想自建一个小工具来管理生活数据,这套流程都可以直接复用。
文章会覆盖完整工程代码、数据库设计、页面展示、常见报错排查,以及项目落地后的扩展方向。所有代码都按照可运行的标准编写,建议你一边读一边跟着敲,效果会更好。
1. 背景与核心概念
1.1 为什么需要“护理数据化”
回到开头那个标题。“我真的 24 岁吗”这个疑问,本质上反映了一种状态:对自身护理效果缺乏判断依据,所以只能靠年龄数字、买过的产品数量、甚至社交平台上的对比图来判断自己“状态好不好”。
这种判断方式有三个问题:
- 护理项目之间没有关联记录,无法回答“皮肤变好是哪一个项目的功劳”。
- 检测报告、面膜频率、美甲周期全部散落在不同聊天记录或手机相册里。
- 无法形成时间线,看不到护理前后趋势。
如果把护理项目看成一种“数据来源”,每次护理、检测、感受都作为一条结构化记录存储,再用时间维度去观察变化,护理决策就能从“我觉得”变成“数据显示”。这就是个人护理数据化管理的基本思路。
1.2 技术方案的选型思路
为了实现这个目标,我们需要一个具备以下能力的系统:
- 记录多种护理类型:经络养护、细胞检测、美甲护理、提拉面膜。
- 支持自定义标签和备注。
- 能按时间线查看所有护理记录。
- 能统计每种护理的频率和花费。
- 能保存检测报告图片或文字结果。
在技术选型上,我没有选择 Spring Boot 或 Django 这类重型框架,而是用 Flask + SQLite 组合。原因很简单:
| 因素 | 说明 |
|---|---|
| Flask 轻量 | 单个 Python 文件即可启动,适合个人工具 |
| SQLite 免安装 | 无需额外启动数据库服务,数据保存在本地文件 |
| 学习成本低 | 后端逻辑清晰,前端用模板渲染即可完成 |
| 扩展方便 | 后续可以替换成 MySQL、PostgreSQL 或增加 REST API |
当然,如果你后续要做多用户、权限管理或高并发访问,Flask + SQLite 就不够用了,需要迁移到更完整的架构。这属于后话,本文先解决“个人工具”这个核心需求。
1.3 系统功能拆解
在动手写代码之前,先明确系统要做什么。以一个真实使用场景为例:
- 周一:做了一次提拉面膜,皮肤紧致感 8 分。
- 周三:去经络美食馆做了一次肩颈养护,经络通畅感 7 分。
- 周五:收到细胞检测报告,显示水分指数偏低。
- 周日:做了成人美甲,款式记录为“裸粉色渐变”。
这些零散信息,在系统里会被拆解为:
- 护理项目(face_mask、meridian、nail_art、cell_test 等)。
- 护理日期。
- 评分(0-10,用于量化主观感受)。
- 花费金额。
- 备注描述。
- 可选图片路径。
然后,系统基于这些数据生成一个“护理时间线”和“频率统计”。当你回头看时,就不再是“美甲做了好几次”,而是“30 天内美甲 4 次,平均花费 89 元,每次间隔 6-8 天”。
这个设计思路,和你在后端项目里定义订单表、用户表本质上是一样的。核心就是:先抽象数据模型,再写业务代码。
2. 环境准备与版本说明
2.1 开发环境
本文示例以 Windows 11 + Python 3.10 为演示环境,实际开发中以下环境也能直接运行:
- 操作系统:Windows 10/11、macOS、Ubuntu 20.04+
- Python 版本:3.8 及以上(推荐 3.10 或 3.11)
- 依赖库:Flask 2.3.x、Jinja2
- 数据库:SQLite3(Python 内置)
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。只要 Flask 主版本保持一致,代码基本不需要修改。
2.2 安装 Flask
建议使用虚拟环境来管理项目依赖,避免污染全局 Python 环境。
打开命令行工具,依次执行:
mkdir care-tracker cd care-tracker python -m venv venv激活虚拟环境:
Windows:
venv\Scripts\activatemacOS / Linux:
source venv/bin/activate激活后,安装 Flask:
pip install flask查看安装版本:
pip show flask输出类似如下内容,说明安装成功:
Name: Flask Version: 2.3.3 Summary: A simple framework for building complex web applications.2.3 项目结构规划
我们在care-tracker目录下按下面结构组织项目:
care-tracker/ ├── app.py # Flask 主程序 ├── requirements.txt # 依赖清单 ├── database.py # 数据库初始化与连接 ├── templates/ │ ├── base.html # 基础页面模板 │ ├── index.html # 首页:护理时间线 │ ├── add_record.html # 新增护理记录 │ ├── stats.html # 统计页面 │ └── detail.html # 单条记录详情 └── static/ └── style.css # 页面样式这是 Flask 非常典型的项目结构。templates目录存放 HTML 模板,static目录存放静态文件,应用启动入口是根目录下的app.py。
3. 核心设计:数据模型与业务逻辑
3.1 数据表设计
护理记录系统核心只需要一张主表,我命名为care_record。
字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY | 自增主键 |
| care_type | TEXT NOT NULL | 护理类型 |
| care_date | TEXT NOT NULL | 护理日期,格式 YYYY-MM-DD |
| rating | INTEGER | 主观评分 0-10 |
| cost | REAL | 花费金额,默认 0 |
| description | TEXT | 备注描述 |
| image_path | TEXT | 图片路径(预留字段) |
| created_at | TEXT | 记录创建时间 |
这里需要特别说明两点:
care_type不直接存中文,而是存英文标识符。因为代码中经常需要比较类型,中文作为 key 容易出现编码或大小写问题。显示时再通过映射表转换为中文。rating是一个简单的主观量化手段。虽然个人感受不等于客观数据,但它可以帮助我们追踪趋势。比如面膜前后紧致感评分变化,就是对护理效果的一种粗略评估。
除此之外,为了避免护理类型乱填,我再建一张care_type_dict表,用来维护可选类型:
| 字段名 | 类型 | 说明 |
|---|---|---|
| type_key | TEXT PRIMARY KEY | 类型标识 |
| type_name | TEXT | 显示名称 |
| category | TEXT | 分类,如 face/body/nail/test |
3.2 核心 SQL 语句
初始化表的 SQL 如下:
CREATE TABLE IF NOT EXISTS care_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, care_type TEXT NOT NULL, care_date TEXT NOT NULL, rating INTEGER DEFAULT 0, cost REAL DEFAULT 0.0, description TEXT DEFAULT '', image_path TEXT DEFAULT '', created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS care_type_dict ( type_key TEXT PRIMARY KEY, type_name TEXT NOT NULL, category TEXT DEFAULT '' );插入初始护理类型数据:
INSERT OR REPLACE INTO care_type_dict (type_key, type_name, category) VALUES ('meridian', '经络养护', 'body'), ('cell_test', '细胞检测', 'test'), ('nail_art', '美甲护理', 'nail'), ('firming_mask', '提拉面膜', 'face'), ('other', '其他', 'other');查询某段时间内所有护理记录,按日期倒序排列:
SELECT * FROM care_record WHERE care_date BETWEEN ? AND ? ORDER BY care_date DESC;统计某类护理总花费:
SELECT care_type, COUNT(*) AS total_count, SUM(cost) AS total_cost FROM care_record GROUP BY care_type;3.3 Flask 分层设计思路
虽然这个项目规模不大,但我还是建议保持清晰分层:
app.py:负责路由和请求处理。database.py:负责数据库连接和所有 SQL 操作。templates/:负责页面渲染。static/style.css:负责页面样式。
在app.py中,我们不直接在路由函数里写 SQL,而是把数据库操作封装成函数,放到database.py。这样做的好处是:如果以后把 SQLite 换成 MySQL,只需要改database.py,路由层的代码不用动。
4. 完整实战案例:护理记录系统开发
4.1 编写数据库操作模块
首先创建database.py,它是整个项目的数据访问层。
# 文件路径:care-tracker/database.py import sqlite3 import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_PATH = os.path.join(BASE_DIR, "care_tracker.db") def get_connection(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db(): conn = get_connection() cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS care_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, care_type TEXT NOT NULL, care_date TEXT NOT NULL, rating INTEGER DEFAULT 0, cost REAL DEFAULT 0.0, description TEXT DEFAULT '', image_path TEXT DEFAULT '', created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS care_type_dict ( type_key TEXT PRIMARY KEY, type_name TEXT NOT NULL, category TEXT DEFAULT '' ) """) type_data = [ ("meridian", "经络养护", "body"), ("cell_test", "细胞检测", "test"), ("nail_art", "美甲护理", "nail"), ("firming_mask", "提拉面膜", "face"), ("other", "其他", "other"), ] cursor.executemany( "INSERT OR REPLACE INTO care_type_dict (type_key, type_name, category) VALUES (?, ?, ?)", type_data, ) conn.commit() conn.close() def add_record(care_type, care_date, rating, cost, description): conn = get_connection() cursor = conn.cursor() cursor.execute( """ INSERT INTO care_record (care_type, care_date, rating, cost, description) VALUES (?, ?, ?, ?, ?) """, (care_type, care_date, rating, cost, description), ) conn.commit() record_id = cursor.lastrowid conn.close() return record_id def get_all_records(): conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT r.*, t.type_name FROM care_record r LEFT JOIN care_type_dict t ON r.care_type = t.type_key ORDER BY r.care_date DESC, r.id DESC """) rows = cursor.fetchall() conn.close() return rows def get_record_by_id(record_id): conn = get_connection() cursor = conn.cursor() cursor.execute( """ SELECT r.*, t.type_name FROM care_record r LEFT JOIN care_type_dict t ON r.care_type = t.type_key WHERE r.id = ? """, (record_id,), ) row = cursor.fetchone() conn.close() return row def get_stats(): conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT care_type, t.type_name, COUNT(*) AS total_count, SUM(cost) AS total_cost, AVG(rating) AS avg_rating FROM care_record r LEFT JOIN care_type_dict t ON r.care_type = t.type_key GROUP BY care_type, t.type_name ORDER BY total_count DESC """) rows = cursor.fetchall() conn.close() return rows def get_type_dict(): conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT type_key, type_name FROM care_type_dict ORDER BY type_key") rows = cursor.fetchall() conn.close() return rows def delete_record(record_id): conn = get_connection() cursor = conn.cursor() cursor.execute("DELETE FROM care_record WHERE id = ?", (record_id,)) conn.commit() affected = cursor.rowcount conn.close() return affected这个模块里需要注意的点:
row_factory = sqlite3.Row让查询结果可以通过字段名访问,而不是索引数字。比如row["care_date"],代码可读性更好。get_connection()每次都创建新连接,用完即关。这种模式在 SQLite 场景下完全够用,因为 SQLite 对并发写入有限制,个人工具并发量低,不需要连接池。init_db()使用CREATE TABLE IF NOT EXISTS和INSERT OR REPLACE,保证重复执行不会报错。
4.2 编写 Flask 主程序
创建app.py,这是整个应用的入口。
# 文件路径:care-tracker/app.py from flask import Flask, render_template, request, redirect, url_for, abort import database app = Flask(__name__) @app.route("/") def index(): records = database.get_all_records() return render_template("index.html", records=records) @app.route("/record/new", methods=["GET", "POST"]) def add_record(): type_dict = database.get_type_dict() if request.method == "POST": care_type = request.form.get("care_type", "").strip() care_date = request.form.get("care_date", "").strip() rating = request.form.get("rating", 0) cost = request.form.get("cost", 0) description = request.form.get("description", "").strip() if not care_type: return "护理类型不能为空", 400 if not care_date: return "护理日期不能为空", 400 try: rating = int(rating) cost = float(cost) except ValueError: return "评分和金额必须是数字", 400 if rating < 0 or rating > 10: return "评分必须在 0 到 10 之间", 400 database.add_record(care_type, care_date, rating, cost, description) return redirect(url_for("index")) return render_template("add_record.html", type_dict=type_dict) @app.route("/record/<int:record_id>") def detail(record_id): record = database.get_record_by_id(record_id) if record is None: abort(404) return render_template("detail.html", record=record) @app.route("/record/<int:record_id>/delete", methods=["POST"]) def delete_record(record_id): affected = database.delete_record(record_id) if affected == 0: abort(404) return redirect(url_for("index")) @app.route("/stats") def stats(): stats_data = database.get_stats() return render_template("stats.html", stats=stats_data) if __name__ == "__main__": database.init_db() app.run(debug=True, host="127.0.0.1", port=5000)这个主程序包含了四个核心路由:
GET /:首页展示所有护理记录,按日期倒序。GET/POST /record/new:新增护理记录。GET /record/<id>:查看单条记录详情。POST /record/<id>/delete:删除记录。GET /stats:查看统计报表。
在POST /record/new中,我做了基础参数校验。为什么要手动校验?因为表单从浏览器传到后端时,所有字段都是字符串,如果不做类型转换和边界校验,用户填入rating=999或cost=abc就会导致数据库存了脏数据,后续统计报错时排查成本很高。
4.3 编写基础模板
项目的templates目录下需要四个 HTML 模板。首先是基础父模板base.html:
<!-- 文件路径:care-tracker/templates/base.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}个人护理记录{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> </head> <body> <header class="site-header"> <div class="container"> <h1>个人护理记录管理</h1> <nav> <a href="{{ url_for('index') }}">时间线</a> <a href="{{ url_for('add_record') }}">新增记录</a> <a href="{{ url_for('stats') }}">统计报表</a> </nav> </div> </header> <main class="container"> {% block content %}{% endblock %} </main> <footer class="site-footer"> <p>Care Tracker · 个人数据管理工具</p> </footer> </body> </html>然后是首页index.html,用表格展示护理时间线:
<!-- 文件路径:care-tracker/templates/index.html --> {% extends "base.html" %} {% block title %}护理时间线{% endblock %} {% block content %} <section class="page-header"> <h2>护理时间线</h2> <p>每一次护理都值得被记录。</p> </section> {% if records %} <table class="record-table"> <thead> <tr> <th>日期</th> <th>类型</th> <th>评分</th> <th>花费</th> <th>备注</th> <th>操作</th> </tr> </thead> <tbody> {% for record in records %} <tr> <td>{{ record["care_date"] }}</td> <td>{{ record["type_name"] or record["care_type"] }}</td> <td class="rating-cell">{{ record["rating"] }}/10</td> <td>¥{{ "%.2f"|format(record["cost"]) }}</td> <td class="desc-cell">{{ record["description"] or "-" }}</td> <td> <a href="{{ url_for('detail', record_id=record['id']) }}">查看</a> <form action="{{ url_for('delete_record', record_id=record['id']) }}" method="post" class="inline-form"> <button type="submit" onclick="return confirm('确定删除这条记录吗?')">删除</button> </form> </td> </tr> {% endfor %} </tbody> </table> {% else %} <p>还没有护理记录,点击右上角“新增记录”开始添加吧。</p> {% endif %} {% endblock %}新增记录页面add_record.html,这里用了select下拉框来选择护理类型:
<!-- 文件路径:care-tracker/templates/add_record.html --> {% extends "base.html" %} {% block title %}新增护理记录{% endblock %} {% block content %} <section class="page-header"> <h2>新增护理记录</h2> <p>统一记录经络养护、细胞检测、美甲、面膜等护理项目。</p> </section> <form method="post" action="{{ url_for('add_record') }}" class="record-form"> <div class="form-group"> <label for="care_type">护理类型</label> <select name="care_type" id="care_type" required> <option value="">请选择类型</option> {% for item in type_dict %} <option value="{{ item['type_key'] }}">{{ item['type_name'] }}</option> {% endfor %} </select> </div> <div class="form-group"> <label for="care_date">护理日期</label> <input type="date" name="care_date" id="care_date" required> </div> <div class="form-group"> <label for="rating">主观评分(0-10)</label> <input type="number" name="rating" id="rating" min="0" max="10" value="5"> <small>0 代表状态很差,10 代表状态极佳</small> </div> <div class="form-group"> <label for="cost">花费金额(¥)</label> <input type="number" name="cost" id="cost" min="0" step="0.01" value="0"> </div> <div class="form-group"> <label for="description">备注描述</label> <textarea name="description" id="description" rows="4" placeholder="例如:面膜敷完后紧致感持续了 4 小时"></textarea> </div> <div class="form-actions"> <button type="submit">保存记录</button> <a href="{{ url_for('index') }}">取消</a> </div> </form> {% endblock %}详情页面detail.html:
<!-- 文件路径:care-tracker/templates/detail.html --> {% extends "base.html" %} {% block title %}记录详情{% endblock %} {% block content %} <section class="page-header"> <h2>记录详情</h2> <a href="{{ url_for('index') }}">返回时间线</a> </section> <article class="detail-card"> <h3>{{ record["type_name"] or record["care_type"] }}</h3> <ul> <li><strong>日期:</strong>{{ record["care_date"] }}</li> <li><strong>评分:</strong>{{ record["rating"] }}/10</li> <li><strong>花费:</strong>¥{{ "%.2f"|format(record["cost"]) }}</li> <li><strong>备注:</strong>{{ record["description"] or "无" }}</li> <li><strong>创建时间:</strong>{{ record["created_at"] }}</li> </ul> </article> {% endblock %}统计页面stats.html:
<!-- 文件路径:care-tracker/templates/stats.html --> {% extends "base.html" %} {% block title %}统计报表{% endblock %} {% block content %} <section class="page-header"> <h2>护理统计报表</h2> <p>按护理类型汇总次数、总花费和平均评分。</p> </section> {% if stats %} <table class="record-table"> <thead> <tr> <th>护理类型</th> <th>次数</th> <th>总花费</th> <th>平均评分</th> </tr> </thead> <tbody> {% for item in stats %} <tr> <td>{{ item["type_name"] or item["care_type"] }}</td> <td>{{ item["total_count"] }}</td> <td>¥{{ "%.2f"|format(item["total_cost"] or 0) }}</td> <td>{{ "%.1f"|format(item["avg_rating"] or 0) }}</td> </tr> {% endfor %} </tbody> </table> {% else %} <p>暂无统计数据,先去添加一条护理记录吧。</p> {% endif %} {% endblock %}4.4 编写页面样式
static/style.css中定义基础样式,重点保证表格和表单在窄屏下也基本可用:
/* 文件路径:care-tracker/static/style.css */ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; margin: 0; background: #f7f7f8; color: #333; } .container { max-width: 960px; margin: 0 auto; padding: 0 16px; } .site-header { background: #fff; border-bottom: 1px solid #eee; padding: 16px 0; } .site-header h1 { display: inline-block; margin: 0; font-size: 20px; } .site-header nav { float: right; line-height: 32px; } .site-header nav a { margin-left: 16px; text-decoration: none; color: #6b4fbb; } .site-header nav a:hover { text-decoration: underline; } .page-header { margin: 24px 0 16px; } .page-header h2 { margin: 0 0 6px; } .page-header p { margin: 0; color: #888; } .record-table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .record-table th, .record-table td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; text-align: left; font-size: 14px; } .record-table th { background: #fafafa; font-weight: 600; } .record-table tr:hover { background: #faf9ff; } .desc-cell { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rating-cell { color: #6b4fbb; font-weight: 600; } .inline-form { display: inline; margin-left: 8px; } .inline-form button { background: none; border: none; color: #d9534f; cursor: pointer; padding: 0; } .record-form { background: #fff; padding: 24px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .form-group { margin-bottom: 18px; } .form-group label { display: block; margin-bottom: 6px; font-weight: 600; } .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .form-actions button { background: #6b4fbb; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-right: 8px; } .detail-card { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .detail-card ul { list-style: none; padding: 0; } .detail-card li { padding: 8px 0; border-bottom: 1px solid #f5f5f5; } .site-footer { text-align: center; color: #aaa; padding: 32px 0 40px; font-size: 13px; }4.5 运行与验证
一切就绪后,在终端执行:
python app.py如果代码没有错误,你会看到类似输出:
* Serving Flask app 'app' * Debug mode: on * Running on http://127.0.0.1:5000打开浏览器,访问http://127.0.0.1:5000。
第一次页面会是空的,因为数据库还没有任何记录。点击导航栏的“新增记录”,填入测试数据:
- 护理类型:提拉面膜
- 日期:2025-01-06
- 评分:8
- 花费:35
- 备注:敷完后紧致感明显,第二天仍然在线
再添加一条:
- 护理类型:经络养护
- 日期:2025-01-08
- 评分:7
- 花费:128
- 备注:肩颈放松后睡眠质量提升
保存后回到时间线页面,你会看到两条记录按日期倒序排列。点击“统计报表”,系统会自动计算出每种护理的次数、总额和平均评分。
运行过程中如果遇到问题,可以对照下一节的排查清单快速定位。
5. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
运行python app.py报ModuleNotFoundError: No module named 'flask' | 没有安装 Flask,或虚拟环境未激活 | 执行pip install flask;确认虚拟环境已激活 |
| 页面提示“护理类型不能为空” | 表单中select没有选中任何值 | 检查模板中name="care_type"是否正确,后端取值字段是否一致 |
| 提交评分时报“评分必须是数字” | 表单输入框传入了非数字字符 | 检查request.form.get("rating")是否被正确接收;前端可加type="number" |
| 删除记录后返回 404 | record_id在数据库中不存在 | 确认删除前是否有其他人删除了同一条记录;检查 SQL 的rowcount |
| 中文显示为乱码 | 终端编码或浏览器编码不正确 | 确认文件保存为 UTF-8 编码;浏览器设置页面编码为 UTF-8 |
重复执行init_db()报错 | 表已存在或类型字典插入数据冲突 | 已使用IF NOT EXISTS和INSERT OR REPLACE,一般不会出现;若手动改过表结构需先备份删除 |
| SQLite 数据库无法写入 | 数据库文件被其他程序占用 | 关闭所有占用该文件的程序;检查是否有两个 Python 进程同时运行 |
下面挑两个高频问题单独展开。
5.1 提交表单返回 400 错误
在 Flask 开发中,最常遇到的是参数校验失败返回的 400。这种问题尤其容易出现在“类型转换”环节。
比如rating字段,如果前端传了一个空字符串,那么request.form.get("rating", 0)拿到的其实是空字符串而不是默认值 0,此时执行int("")就会抛ValueError。
解决方案是先用.strip()处理,再判断是否为空,最后再做类型转换。更稳妥的判断方式:
rating_raw = request.form.get("rating", "").strip() if not rating_raw: rating = 0 else: try: rating = int(rating_raw) except ValueError: return "评分必须是数字", 4005.2 Flask debug 模式下热更新导致重复初始化
当debug=True时,Flask 会自动加载一个 reloader,代码变动时进程会重启。如果database.init_db()写在主模块顶层,重启时会重复执行。
虽然CREATE TABLE IF NOT EXISTS不会报错,但如果你在初始化函数里写了其他操作,比如删除旧表重建,就会导致数据丢失。
所以更推荐的方式是:在if __name__ == "__main__":中调用database.init_db(),保证只在主动运行app.py时执行一次初始化。
6. 最佳实践与工程建议
6.1 使用表单校验而不是只依赖前端验证
浏览器端的required和type="number"只能拦住一般用户,直接通过 curl 或 Postman 提交请求时,这些校验全部失效。所以服务端校验才是最后防线。
本项目的原则是:
- 所有字段必须经过
.strip()。 - 数字字段统一
try/except转换。 - 字符串长度有限制,避免插入超长备注。
- 枚举类字段(比如
care_type)必须校验是否在类型字典中。
6.2 用时间线索引优化查询
当前数据量小,SELECT * ... ORDER BY care_date DESC没有任何性能问题。但如果护理记录积累到几千条甚至更多,建议在care_date字段上建索引:
CREATE INDEX IF NOT EXISTS idx_care_record_date ON care_record (care_date DESC);这一步在 SQLite 中非常轻量,却能让时间范围查询的扫描行数显著减少。
6.3 备份数据库文件
SQLite 的好处是数据自己一个文件,备份就是复制文件。但坏处是如果误删care_tracker.db,数据就全部丢失。
推荐做法:
- 定期把
care_tracker.db备份到另一个目录或云存储。 - 删除记录操作一定要二次确认,本项目已经在按钮上加了
confirm()弹窗。 - 后续可以增加一个“导出 JSON”功能,把数据表整体导出,方便迁移或做数据分析。
6.4 将护理类型字典抽出为配置文件
项目里care_type_dict直接写死在init_db()中,好处是简单,坏处是每次新增类型都要改代码。
更合理的做法是把类型字典放到一个独立的 JSON 文件或配置表中,例如care_types.json:
{ "meridian": {"name": "经络养护", "category": "body"}, "cell_test": {"name": "细胞检测", "category": "test"}, "nail_art": {"name": "美甲护理", "category": "nail"}, "firming_mask": {"name": "提拉面膜", "category": "face"}, "other": {"name": "其他", "category": "other"} }新增护理类型时,只需要修改 JSON,不需要修改database.py。对个人工具来说,这种配置比硬编码更友好。
6.5 权限与安全边界
虽然这只是个人工具,但如果你部署到局域网或公网,必须考虑安全:
- 不要把
debug=True用于生产环境,否则攻击者可能获取源码或执行任意代码。 - 如果服务绑定到
0.0.0.0,等于向整个网络开放,强烈不建议在无认证的情况下开放。 - 增加一个简单登录密码,使用 Flask 的
session维持登录状态。 - 所有用户输入通过
escape()或模板自带的自动转义处理,防止 XSS。
本项目使用 Jinja2 模板渲染,默认开启了自动转义,所以普通备注文本是安全的。但如果你新增了富文本编辑或 Markdown 渲染,需要额外关注 XSS 风险。
6.6 日志记录
个人工具也要有最基本的日志。可以在 Flask 中配置 logging,记录:
- 每次新增记录的操作者与内容摘要。
- 每次删除操作的 ID 和删除时间。
- 参数校验失败的请求来源。
一个简单的处理方式:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[logging.FileHandler("app.log", encoding="utf-8")], )然后在新增和删除路由中加日志输出。这样即使误操作,也能通过日志找回线索。
7. 总结与下一步规划
到现在为止,你已经完成了一个可运行的护理记录系统,核心能力包括:
- 记录多种护理类型,包括经络养护、细胞检测、美甲护理、提拉面膜。
- 通过时间线页面追踪所有历史记录。
- 通过统计报表汇总每种护理的次数、花费和平均评分。
- 支持新增、查看、删除操作。
- 服务端参数校验和基础安全设计。
这也回答了我们开头的问题:24 岁状态好不好,不应该依靠一个飘忽的“感觉”,而是让数据帮你复盘。回到技术上,这套系统虽然是一个个人小工具,但它的架构思路完全可以迁移到更复杂的业务场景中。
如果你想把项目继续深化,建议从以下方向中选择一个:
- 增加图片上传。细胞检测报告、美甲成品图、面膜外包装都可以作为图片附件存储,记录更完整。
- 增加趋势图。用 ECharts 或 Chart.js 按月份绘制平均评分曲线,观察长期趋势。
- 增加费用预算。每种护理类型设置月度预算,超支时给出提醒。
- 增加数据导入导出。导出成 Excel 或 CSV,方便用数据分析库做更复杂的处理。
- 部署到服务器,用 docker-compose 管理 Flask 应用和数据库。
最后给你一个实际项目中非常有用的建议:数据库文件不管当前多小,都要养成备份习惯。你在本地跑得再顺畅,数据一旦丢失也只能从头开始。下一步动手写代码前,先想清楚你要记录哪些字段、统计哪些指标,这比优化代码更重要。