Flask+SQLite实战:从零构建带用户系统的待办事项管理应用

flasksqlite用户系统 本内容遵循CC 4.0 BY-SA版权协议
橙心橙怡 橙心橙怡 墨衍会员 · AI 创作全网分发 墨衍智能分发 通过墨衍一键同步至各平台 可同步平台 CSDN微信公众号微博知乎掘金百家号博客园抖音小红书 你的文章也可以这样分发 墨衍会员 ¥399起/年,写一次发全网 我也要

翻开一本编程教程的最后一章,看到"综合实战项目"这几个字,大部分人心情应该跟我差不多:又兴奋又发怵。兴奋的是前面十章的语法、函数、文件操作、异常处理这些零散知识点,终于要在一条完整的项目链里串起来了;发怵的是万一哪一块学得不够扎实,这一章就会变成大型翻车现场。我最近刷的是一本Python Web方向的教程,第11章的综合实战项目要求用Flask从零搭一个带用户系统的待办事项管理应用。它最大的价值不是逼你再记几个函数,而是逼你回答一个关键问题:面对一个模糊的需求,你该怎么把它拆成可动手的代码、再落到一个能跑起来的Web应用上。这篇文章把我完整的实操过程记录下来,你既可以直接照着敲一遍,也可以重点看我标注的踩坑与设计判断。

1. 实战项目全景:这一章到底在练什么

1.1 项目选题拆解与需求分析

先聊选题。教程把综合实战项目定为"待办事项管理系统",而不是电商平台、社交App这类大而全的东西,我后来才琢磨明白这个选择的巧妙之处。

一个待办事项应用的核心需求特别清晰:用户注册登录、添加任务、查看任务列表、标记完成、删除任务。这五项需求刚好覆盖了Web开发的主干链条——数据模型设计、路由处理、模板渲染、表单交互、会话管理。再往下拆,每项需求又可以展开:

  • 用户注册登录:涉及密码的安全存储、Session会话保持、登录态校验。
  • 添加任务:涉及POST请求、表单取值、数据库插入。
  • 查看任务列表:涉及GET请求、数据查询、模板循环渲染。
  • 标记完成与删除:涉及URL参数传递、数据库更新、删除操作。

这个难度系数卡得刚刚好。如果项目太重,比如做一个完整商城,新手很容易被困在支付、库存、订单状态这些业务逻辑里,反而忘了练代码本身;如果太轻,比如只做一个"hello world"页面,那就完全达不到综合练习的目的。待办事项应用是一个"麻雀虽小五脏俱全"的选择,它逼着你走完一遍完整的Web开发流程,但又不至于让你在非核心的地方耗尽精力。

我还留意到,教程在需求描述里特意加了一条:每个用户只能看到自己的待办事项。这个看似不起眼的需求,直接决定了数据库表结构的设计——todo表必须关联user_id,所有查询都必须带当前用户的ID作为条件。这个细节正是"综合"二字的体现,它把一个安全相关的设计判断藏在了需求里,等着你去发现。

1.2 技术栈选型的背后逻辑

教程选的技术栈是Flask加SQLite加Jinja2模板引擎,前端不引框架,纯手写HTML和简单CSS。这个选型我一开始不太理解,毕竟现在主流都是前后端分离、Vue/React一把梭。但真正跟着做了一遍之后,我承认这个组合在教学的场景下确实是合理的。

Flask属于微框架,它不像Django那样自带ORM、Admin后台、表单组件等全套装备。但也正因为"缺东西",你才有机会手写路由、手动连数据库、自己构造SQL语句。如果你直接上Django,ORM把它底层的数据库交互全都封装好了,你反而不理解一条数据到底是怎么从数据库跑到页面上的。

SQLite的选择也很务实。它就是一个单文件数据库,不需要单独安装数据库服务端,Python标准库自带sqlite3模块。对于教学项目,你不需要考虑什么高并发、复杂连接池,把注意力集中在"SQL怎么写、数据怎么读"上就够了。

前端不引框架的理由更简单。Jinja2模板渲染天然支持把后端数据塞进HTML,服务端渲染的交互逻辑足够直观。如果这时候再引入Vue或者React,等于把一个综合实战项目变成了"双线作战"——你既要学后端业务,又要应付前端的响应式状态管理,很多人会直接卡死在前端这一层。

技术栈定下来之后,整个项目的代码组织方式也就有了雏形。我先把目录结构和数据库表设计列出来,这是后面所有代码的基础。

2. 动手前的三项准备

2.1 环境搭建与虚拟环境隔离

动手第一步,永远是搭环境。我知道有些同学会嫌麻烦直接用全局Python环境,pip install flask就直接写了。但虚拟环境这一步,真的是第11章里最值得养成习惯的操作。

虚拟环境的核心作用,就是给每个项目隔离一套独立的Python解释器和第三方依赖包。没有它,你装了A项目要的Flask 2.0,再装B项目要的Django 4.0,依赖之间互相打架是家常便饭。教程里用的是Python自带的venv模块,操作很简单:

BASH
# 创建虚拟环境(在项目目录下执行)
python -m venv venv
 
# 激活虚拟环境(Windows)
venv\Scripts\activate
 
# 激活虚拟环境(macOS/Linux)
source venv/bin/activate

激活之后,命令行提示符前面会出现一个(venv)标记。这个时候再装依赖,就只会装到当前项目的环境里:

BASH
pip install flask

装完之后可以用pip list确认Flask及其依赖Werkzeug、Jinja2已经就位。

这一步有一个特别容易踩的坑:很多人忘了激活虚拟环境,直接pip install flask,结果包装到了全局环境里,代码运行无报错但项目环境里却找不到Flask。更迷惑的是,如果全局环境和虚拟环境版本不一致,还会出现"本地能跑、换个环境就跑不了"的诡异问题。所以每次新开终端窗口,第一件事就是确认(venv)标记还在。

2.2 目录结构与数据库表设计

我按照教程的建议,把项目组织成下面这个结构。这个结构对Flask项目来说非常经典:

TEXT
todo_app/
├── app.py # 应用入口与路由
├── models.py # 数据库操作封装
├── templates/ # HTML模板
│ ├── base.html
│ ├── login.html
│ ├── register.html
│ └── index.html
└── static/
└── style.css # 样式文件

数据库用SQLite,建表逻辑写在models.py里。我先把表结构贴出来,再解释字段为什么这样设计。

PYTHON
import sqlite3
from werkzeug.security import generate_password_hash, check_password_hash
 
DB_PATH = "todo.db"
 
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
 
def init_db():
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
desc TEXT,
is_done INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id)
)
""")
conn.commit()
conn.close()

这里有两个设计值得单独拎出来说。

第一个是password_hash字段。绝不能把用户密码明文存进数据库,这是所有带用户系统的应用最底线的安全要求。Werkzeug库提供的generate_password_hash函数默认使用PBKDF2算法,它会给同一个密码生成不同的随机盐值再加盐哈希。验证时用check_password_hash比对。这样一来,就算数据库泄露,攻击者拿到的也是一串不可逆的哈希值,而不是密码明文。

第二个是todo表通过user_id外键关联user表。每个待办事项都记录它是属于哪个用户的,后面所有查询都要带上user_id条件,这一步是实现"每个用户只看得到自己的数据"的关键。如果没有这个字段,所有任务就会混成一锅粥,也就谈不上用户隔离了。

2.3 初始化脚本与启动入口

光有建表语句不够,你要在合适的地方调用一次init_db(),数据库文件才会真正生成。我放在app.py里,在创建Flask应用实例的同时执行:

PYTHON
from flask import Flask, render_template, request, redirect, url_for, session, flash
from functools import wraps
import models
 
app = Flask(__name__)
app.secret_key = "dev-secret-key-change-in-production"
 
models.init_db()

secret_key这个参数也很重要。Flask用它在服务端签名Session Cookie,如果这里不设置,Session功能基本就没法稳定工作。教程环境用固定字符串没问题,但实际发布时必须改成随机值,最好通过环境变量注入,绝不能把这个"开发用密钥"直接带到线上环境。

3. 核心功能逐个落地

3.1 注册与登录:会话管理从零实现

数据库准备好了,接下来写用户体系。注册路由的逻辑是:GET请求返回注册页面,POST请求接收表单数据,写进数据库,然后跳转到登录页面。

PYTHON
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "").strip()
 
if not username or not password:
flash("用户名和密码不能为空")
return render_template("register.html")
 
conn = models.get_db()
try:
conn.execute(
"INSERT INTO user (username, password_hash) VALUES (?, ?)",
(username, models.generate_password_hash(password)),
)
conn.commit()
flash("注册成功,请登录")
return redirect(url_for("login"))
except sqlite3.IntegrityError:
flash("用户名已存在,换一个试试")
finally:
conn.close()
 
return render_template("register.html")

注意这里对空值的处理有两个层面。一是HTML表单里加了required属性,前端就能拦截空提交;二是后端依然要校验一遍。我说句实在话,前端校验只是为了用户体验,后端校验才是安全底线。绕过前端直接POST请求是完全可能发生的,所以后端必须把这道关。

登录路由逻辑也是类似的两段式处理:

PYTHON
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "").strip()
 
conn = models.get_db()
user = conn.execute(
"SELECT * FROM user WHERE username = ?", (username,)
).fetchone()
conn.close()
 
if user and models.check_password_hash(user["password_hash"], password):
session["user_id"] = user["id"]
session["username"] = user["username"]
return redirect(url_for("todos"))
else:
flash("用户名或密码错误")
 
return render_template("login.html")

登录成功之后,把user_id和username写进session。后续每个请求都能从session里读出当前登录用户,这就是会话管理的基本原理。要理解"会话"这个概念,你可以把它想象成一张临时通行证——用户在登录时拿到通行证,后续每次访问都在门口亮一下,服务器看到有效通行证就放行,看到无效的就送回登录页。

3.2 登录校验装饰器:重复代码的优雅封装

待办事项的页面只能让登录用户访问,所以我需要一个统一的校验逻辑。最直接的方式是在每个需要用登录态的路由里写一遍"检查session里有没有user_id",但这样重复劳动太蠢了。Python装饰器在这里派上了用场:

PYTHON
def login_required(f):
@wraps(f)
def decorated_func(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated_func

使用的时候,只要在路由函数上方加一行@login_required,就能实现"登录才能访问"的约束。functools.wraps的作用是保留原函数的元信息,保证装饰器不会把函数名、docstring这些信息弄丢。这个模式在Web开发里非常常用,理解了它,后面看其他框架的中间件概念也会容易很多。

3.3 待办事项的增删改查:核心循环

待办事项的四项操作,对应四个路由函数,分别是展示列表、新增、标记完成、删除。

先看展示和新增:

PYTHON
@app.route("/todos")
@login_required
def todos():
user_id = session["user_id"]
conn = models.get_db()
items = conn.execute(
"SELECT * FROM todo WHERE user_id = ? ORDER BY is_done ASC, created_at DESC",
(user_id,),
).fetchall()
conn.close()
return render_template("index.html", items=items)
 
 
@app.route("/add", methods=["POST"])
@login_required
def add_todo():
title = request.form.get("title", "").strip()
desc = request.form.get("desc", "").strip()
 
if not title:
flash("任务标题不能为空")
return redirect(url_for("todos"))
 
conn = models.get_db()
conn.execute(
"INSERT INTO todo (user_id, title, desc) VALUES (?, ?, ?)",
(session["user_id"], title, desc),
)
conn.commit()
conn.close()
return redirect(url_for("todos"))

ORDER BY is_done ASC是我后期优化时加的。它的效果是未完成的任务排在前面,已完成的任务沉到底部,配合created_at DESC能让新任务靠前展示,排序逻辑比单纯按时间排要实用得多。

标记完成和删除的代码逻辑非常相似,核心都是先定位记录、再执行更新或删除。但这里面藏着一个特别重要的细节,我必须单独标注出来:

PYTHON
@app.route("/done/<int:todo_id>")
@login_required
def mark_done(todo_id):
conn = models.get_db()
conn.execute(
"UPDATE todo SET is_done = 1 WHERE id = ? AND user_id = ?",
(todo_id, session["user_id"]),
)
conn.commit()
conn.close()
return redirect(url_for("todos"))
 
 
@app.route("/delete/<int:todo_id>")
@login_required
def delete_todo(todo_id):
conn = models.get_db()
conn.execute(
"DELETE FROM todo WHERE id = ? AND user_id = ?",
(todo_id, session["user_id"]),
)
conn.commit()
conn.close()
return redirect(url_for("todos"))

看到了吗?SQL语句里的条件不只是id = ?,而是id = ? AND user_id = ?。这个user_id条件,就是数据隔离的防线。如果没有它,我把URL改成/delete/1就能删掉别人的任务,改成/done/3就能把别人的任务标记成已完成。这是一个典型的越权漏洞,最可怕的是它没有任何报错——攻击者不会看到"没有权限"的提示,因为服务器根本不认识他。

这种问题在Web安全里叫IDOR(不安全的直接对象引用)。教程虽然只是一笔带过,但它正是综合实战项目里最值得深入研究的安全知识点。我建议你测试的时候专门试试不带user_id条件会怎样,感受一下这个权限校验的价值。

3.4 模板层:Jinja2把数据变成页面

后端把数据查出来了,接下来就是把它变成用户在浏览器里看到的页面。Jinja2模板语法不复杂,核心就是{% 控制语句 %}和{{ 变量输出 }}两种。

base.html是所有页面的骨架,用模板继承来复用公共部分:

HTML
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{% block title %}待办事项{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<header>
<h1>待办事项管理</h1>
{% if session.get('user_id') %}
<span>当前用户:{{ session.get('username') }}</span>
<a href="/logout">退出登录</a>
{% endif %}
</header>
 
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="flash">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
 
{% block content %}{% endblock %}
</div>
</body>
</html>

url_for('static', filename='style.css')是Jinja2里生成静态文件路径的标准方式。它的好处是你改了应用挂载路径,静态文件的地址也会跟着自动更新,不会出现硬编码路径失效的问题。

index.html里,任务列表的渲染是重头戏:

HTML
{% extends 'base.html' %}
 
{% block content %}
<h2>添加新任务</h2>
<form method="post" action="/add">
<input type="text" name="title" placeholder="任务标题" required>
<input type="text" name="desc" placeholder="备注(可选)">
<button type="submit">添加</button>
</form>
 
<h2>任务列表</h2>
{% if items %}
<ul>
{% for item in items %}
<li class="{{ 'done' if item['is_done'] else '' }}">
<span>{{ item['title'] }}</span>
{% if item['desc'] %}
<em>{{ item['desc'] }}</em>
{% endif %}
 
{% if not item['is_done'] %}
<a href="/done/{{ item['id'] }}">标记完成</a>
{% else %}
<span class="badge">已完成</span>
{% endif %}
 
<a href="/delete/{{ item['id'] }}" onclick="return confirm('确定删除这条任务吗?')">删除</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>还没有任务,先添加一条吧。</p>
{% endif %}
{% endblock %}

这段模板里有几个值得留意的点。首先是{{ item['title'] }}和{{ item['desc'] }},Jinja2会自动对输出的变量做HTML转义。也就是说,如果用户输入了<script>alert('xss')</script>这类内容,页面会把尖括号原样显示出来,而不是当作浏览器脚本执行。这是模板引擎内置的XSS防护,写模板时不需要额外的处理,但你要知道这个机制存在。

其次是onclick="return confirm(...)"。这是一个纯前端的小优化,在跳转到删除链接之前先弹出一个确认框,防止用户手滑删除。这个交互改进成本极低,但使用体验提升明显。后端当然无法依赖它做安全防护,因为它绕不过去——攻击者可以直接请求删除URL。但它对普通用户来说,是一道很有用的"操作保险"。

登录页和注册页的模板更简单,就是两个表单加基本的样式。这里我不再展开。前端页面全部用一条简单的style.css配上白底卡片式布局,页面就能看得过去了。综合实战项目阶段,把更多精力放在后端逻辑上是对的,CSS能做到整洁清晰足够。

4. 调试、踩坑与上线前检查

4.1 本地联调:从启动到完整流程走通

所有代码写完,接下来就是检验成果的时刻。启动Flask开发服务器:

BASH
python app.py

如果一切正常,控制台会显示Running on http://127.0.0.1:5000。打开浏览器访问这个地址,完整跑一遍流程:注册一个账号、用这个账号登录、添加几条任务、标记完成、删除其中一条、退出登录、再注册一个新账号验证看不到旧账号的任务。

这一步是最容易暴露问题的环节。我实操时遇到的第一坑是:注册成功后跳转到登录页,输对了账号密码却提示"用户名或密码错误"。我检查了大半天才发现,问题出在注册路由里——models.generate_password_hash(password)调用的是models.py中封装好的函数,而登录验证用的是models.check_password_hash(user["password_hash"], password)。听起来没问题对吧?但我在注册时因为粗心写成了generate_password_hash(username),把用户名当作密码去哈希了。这种错误在代码里非常隐蔽,因为注册时数据库写入是成功的,登录时才暴露出"根本对不上"的问题。

排查这类问题我推荐一个土办法:在关键位置加print()输出,把中间值打出来。比如在登录时打印出user["password_hash"]和check_password_hash的返回值,看看是哪一层出了错。等所有问题修完再把这些调试代码删掉。对于第11章这种规模的实战项目,用不上多复杂的调试工具,print大法足够高效。

4.2 高频错误与排查速查表

我把实操过程中最容易出现的坑整理成一张速查表,这些错误在网络问答社区里出现频率也很高:

错误现象 可能原因 解决办法
sqlite3.OperationalError: no such table: user 忘了调用init_db(),或者建表失败 确认启动时执行了models.init_db(),删除旧的todo.db后重新生成
ImportError: No module named 'flask' 虚拟环境没激活,或Flask没装到这个环境里 检查命令行是否存在(venv)前缀,重新pip install flask
注册提示用户名已存在 想测试不同用户,但用户名重复 换一个用户名,或者手动删除数据库里旧记录
登录后仍跳回登录页 Session写入失败,或者secret_key没设置 确认app.secret_key已赋值,检查session["user_id"]是否写入
模板页面显示原始HTML 模板文件命名或路径不对,渲染没有生效 确认模板文件在templates/目录,且目录在app.py同层
Bad Request: The CSRF token is missing 使用了带CSRF防护模块(非本教程场景) 本教程未引入基础Flask的CSRF,若你加了扩展需配置密钥

其中"登录后仍跳回登录页"这个问题最容易误导人。它的根源往往是secret_key没有设置,导致session签名失效,Flask每次请求都无法从Cookie中恢复用户状态,看起来就像"登录成功了但马上又被踢出来"。这种问题你盯着登录逻辑的代码根本看不出毛病,因为问题根本不在这里。

4.3 上线前的安全检查与优化方向

项目能跑通之后,教程进入收尾阶段。这时候有几项检查我建议你认真做一遍,因为"能跑"和"能上线"之间还有不小的距离。

第一是secret_key。开发时用写死的字符串没问题,但真上线前必须改成随机值,并且从环境变量读取。硬编码的密钥等于把你的会话加密密钥公布给了所有人,别人可以伪造Session Cookie。第二是debug=True。开发模式下Flask会展示详细的错误堆栈,方便调试。但这个模式绝不能开在生产环境——它会把源码路径、局部变量全部暴露给访问者,相当于主动给攻击者递了一份你的代码地图。

第三是数据库备份。SQLite就是单文件,备份方式极其简单,直接把todo.db复制一份放到安全位置即可。你可以写一个简单的定时脚本定期复制文件,也可以手动在每次大改动前复制一次。数据无价,这个习惯越早养成越好。

再往后如果你想继续扩展这个项目,自然的演进方向有很多:给任务加截止日期和优先级字段,用datetime做提醒;增加编辑功能,让用户可以修改任务内容;给密码找回加邮箱验证;把单纯的HTML页面升级为前端交互更丰富的界面。这些都是从综合实战项目通往真实业务系统的成长路径。

写在最后

这一章做下来,我的感觉是:第11章的本质不是为了让你学会Flask,而是让你亲身体验一次"从零到一交付一个项目"的完整过程。它逼着你同时去想三个问题——数据怎么建模、代码怎么组织、安全怎么兜底。这些能力在单章知识点里根本练不到。

我实操中最大的一个体会是:一定要亲手把代码敲一遍,而不是复制粘贴。复制粘贴代码的时候,很多细节会在你眼前溜走,比如装饰器里忘了加@wraps(f)、SQL查询漏了user_id条件、模板里写错变量名。这些错误只有亲手敲过之后踩一次坑,你才会真正记住。我第一遍跑通用了整整一个下午,期间至少犯了五个低级错误,但正是这个下午的折腾,让我对Flask应用从启动到响应再到数据库落盘的整个流程有了实实在在的体感。这个体感,才是综合实战项目给你留下的最宝贵的东西。

相关推荐

基于Python+Open CV的手势识别算法设计

伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。

努力学习前端的小陈

ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像

ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)

Willis Wang

计算机视觉图像处理-CV2与图像插值算法

用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int

weixin_38697274

基于水平集CV模型的图像分割(Matlab代码)

基于水平集CV模型的图像分割(Matlab代码),全套代码

小宇QAQ

导向滤波(guide_filter)的python实现

导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

zachzhan_g
返回顶部
橙心橙怡
博客等级 码龄8年 44粉丝 8,143原创