This commit is contained in:
2026-08-08 15:49:06 +08:00
parent e7dce1680a
commit 0a7207fa0e
76 changed files with 22779 additions and 0 deletions
+835
View File
@@ -0,0 +1,835 @@
"""
app.py — Flask 后端主文件
=========================
作用:整个 MVP 的"大脑",负责三件事:
1. 提供 API 接口(视频列表、AI 对话、行为记录)
2. 管理 SQLite 数据库(用户、视频、行为日志)
3. 对接 DeepSeek 大模型 API
产品概念说明:
- 这里用的是 "单体应用" 架构:一个 app.py 把路由+数据库+API 全包了。
适合 MVP 阶段快速验证,后续用户量起来再拆分为微服务。
- 路由 (Route):浏览器访问不同网址时,Flask 把请求分发给对应的函数处理。
类比:路由 = 你走进商场,问服务台"女装在三楼",服务台就是路由层的功能。
"""
import gzip
import json
import os
import sqlite3
from datetime import timedelta
import requests
from flask import Flask, render_template, request, jsonify, send_from_directory
# ---- 初始化 Flask 应用 ----
app = Flask(__name__)
# 静态资源缓存 7 天(模板引用带 ?v= 版本号,更新时递增即可刷新)
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = timedelta(days=7)
# 配置路径 (使用绝对路径以避免 sqlite 找不到文件)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data").replace("\\", "/")
# 修改数据库文件名,绕过之前的旧文件锁定问题
DB_PATH = os.path.join(DATA_DIR, "miniapp_v3.db").replace("\\", "/")
VIDEOS_PATH = os.path.join(DATA_DIR, "seed_videos.json").replace("\\", "/")
STORY_PATH = os.path.join(DATA_DIR, "seed_story.json").replace("\\", "/")
MATH_TEEN_STORY_PATH = os.path.join(DATA_DIR, "数学少年线_story.json").replace("\\", "/")
SIMULATOR_PATH = os.path.join(DATA_DIR, "seed_simulator.json").replace("\\", "/")
MATHBTI_PATH = os.path.join(DATA_DIR, "seed_mathbti.json").replace("\\", "/")
ALUMNI_PATH = os.path.join(DATA_DIR, "seed_alumni.json").replace("\\", "/")
# 确保 data 目录存在
os.makedirs(DATA_DIR, exist_ok=True)
# 内容型 GET API 短缓存(种子数据极少变动)
_API_CACHE_SECONDS = {
'/api/videos': 300,
'/api/knowledge': 300,
'/api/story': 300,
'/api/simulator': 300,
'/api/alumni': 300,
'/api/math_personality': 300,
}
@app.after_request
def compress_response(response):
"""gzip 压缩文本响应(HTML/CSS/JS/JSON >500B+ 内容型 API 短缓存"""
if request.method == 'GET' and request.path in _API_CACHE_SECONDS:
response.headers['Cache-Control'] = f"private, max-age={_API_CACHE_SECONDS[request.path]}"
if (response.status_code != 200
or 'Content-Encoding' in response.headers):
return response
if 'gzip' not in request.headers.get('Accept-Encoding', ''):
return response
mimetype = response.mimetype or ''
if not (mimetype.startswith('text/') or mimetype in ('application/json', 'application/javascript')):
return response
if response.direct_passthrough:
response.direct_passthrough = False # 静态文件需读入内存才能压缩
data = response.get_data()
if len(data) < 500:
return response
compressed = gzip.compress(data, compresslevel=6)
response.set_data(compressed)
response.headers['Content-Encoding'] = 'gzip'
response.headers['Content-Length'] = len(compressed)
response.headers['Vary'] = 'Accept-Encoding'
return response
# DeepSeek API 配置 — 需要用户填入自己的 API Key
DEEPSEEK_API_KEY = "sk-0cd8b068c4a3439c9fc6d67b08865c21"
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
# ============================================================
# 模块 1: 数据库初始化
# ============================================================
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS behavior_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
action TEXT,
target TEXT,
detail TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 旧库迁移:补充 detail 列
try:
c.execute("ALTER TABLE behavior_log ADD COLUMN detail TEXT")
except Exception:
pass
# 知识卡片表
c.execute("""
CREATE TABLE IF NOT EXISTS knowledge_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
question TEXT,
description TEXT,
detail TEXT,
color TEXT DEFAULT '#3B82F6',
icon TEXT DEFAULT '📖',
topic TEXT,
read_time TEXT DEFAULT '5分钟阅读',
sort_order INTEGER DEFAULT 0
)
""")
# 收藏表
c.execute("""
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
title TEXT,
cover TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, target_type, target_id)
)
""")
# 观看历史表
c.execute("""
CREATE TABLE IF NOT EXISTS watch_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
title TEXT,
cover TEXT,
watched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 用户表
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT UNIQUE NOT NULL,
nickname TEXT DEFAULT '游客用户',
avatar_emoji TEXT DEFAULT 'π',
avatar_color TEXT DEFAULT '#5B6AF0',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 学生表(B端看板)
c.execute("""
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id TEXT UNIQUE,
name TEXT,
class_name TEXT,
grade TEXT
)
""")
# 常用查询索引
c.execute("CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_history_user ON watch_history(user_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_behavior_user ON behavior_log(user_id)")
conn.commit()
conn.close()
def load_seed_knowledge():
"""加载知识卡片种子数据"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM knowledge_cards")
if c.fetchone()[0] > 0:
conn.close()
return
cards = [
{"title": "被平均数骗过的所有人", "question": "你和马云的平均财富是多少?", "desc": "平均数、中位数、众数——这三兄弟让你看到的「真相」可能截然相反。", "detail": "平均数是最常用的统计量,但它也是最容易被误解的。当数据中有极端值时,平均数会被拉偏。比如你和马云的平均财富可能是千亿级别,但中位数可能只有几万。了解平均数、中位数、众数的区别,是统计学入门的第一课。", "color": "#EF4444", "icon": "📊", "topic": "统计陷阱", "read_time": "5分钟阅读", "sort": 1},
{"title": "彩票:一场精心设计的数学税", "question": "2元博500万,值不值?", "desc": "你中头奖的概率比你连续被雷劈三次还低。但为什么每天都有人买?", "detail": "双色球头奖概率约1/1772万。对比:被雷劈的概率约1/100万。彩票的期望值永远是负的——这是彩票公司盈利的数学基础。人们购买彩票不是因为他们不懂数学,而是因为损失厌恶和过度乐观的认知偏差。", "color": "#F59E0B", "icon": "🎲", "topic": "概率思维", "read_time": "5分钟阅读", "sort": 2},
{"title": "为什么所有地图都在骗你", "question": "格陵兰岛有非洲大吗?", "desc": "把球面摊平一定会变形——高斯发现的绝妙定理决定了你看到的世界。", "detail": "墨卡托投影让格陵兰岛看起来和非洲差不多大,实际上非洲面积是格陵兰的14倍。球面无法完美展开成平面,这是高斯的'绝妙定理'Theorema Egregium)决定的。每种地图投影都有取舍:要么保面积,要么保角度,要么保方向——你永远无法三者兼得。", "color": "#3B82F6", "icon": "🗺️", "topic": "几何直觉", "read_time": "5分钟阅读", "sort": 3},
{"title": "大自然最爱的数列", "question": "兔子繁殖与斐波那契", "desc": "从向日葵的花瓣到鹦鹉螺的壳,斐波那契数列无处不在。这是巧合吗?", "detail": "斐波那契数列:1, 1, 2, 3, 5, 8, 13, 21... 后一项等于前两项之和。这个简单的规则在大自然中反复出现:松果的螺旋数、花瓣的排列、菠萝的鳞片——都是斐波那契数。这不是巧合,而是植物在生长过程中最优空间利用的结果。", "color": "#8B5CF6", "icon": "🔢", "topic": "数列之美", "read_time": "6分钟阅读", "sort": 4},
{"title": "博弈论:竞争的最优解", "question": "为什么奶茶店总是开在隔壁?", "desc": "纳什均衡解释了一个奇怪的现象:竞争对手为什么总是扎堆?", "detail": "两个冰淇淋摊在海滩上竞争,最后都会摆到海滩正中间——这就是霍特林模型。纳什均衡告诉我们:当每个人都做出对自己最优的选择时,结果可能对所有人都不好。这个原理适用于从商业选址到交通拥堵的各种场景。", "color": "#10B981", "icon": "⚖️", "topic": "博弈策略", "read_time": "5分钟阅读", "sort": 5},
{"title": "混沌理论:看不见的秩序", "question": "一只蝴蝶能引起台风吗?", "desc": "天气预报为什么不准?非线性动力学如何揭示混乱中的规律。", "detail": "洛伦兹在1961年发现:输入数据小数点后三位的微小差异,会导致完全不同的天气预报结果。这就是'蝴蝶效应'。混沌理论最迷人的地方在于:混沌不是随机——它是确定的系统产生不可预测的行为。这种'确定性的随机'挑战了我们对因果关系的直觉。", "color": "#EC4899", "icon": "🦋", "topic": "混沌理论", "read_time": "7分钟阅读", "sort": 6},
{"title": "蒙提霍尔:三门悖论", "question": "主持人打开一扇空门后,你该换吗?", "desc": "一个让数学家和大众都吵翻天的概率谜题。", "detail": "游戏节目有三扇门:一扇后面是汽车,两扇后面是山羊。你选择一扇后,主持人打开另一扇有山羊的门。现在你可以坚持原来的选择或换到剩下那扇——换门的中奖概率是2/3,不是1/2。这个结论让无数人(包括数学家)抓狂,但它是对的。关键:主持人的行为提供了信息。", "color": "#06B6D4", "icon": "🚪", "topic": "概率思维", "read_time": "4分钟阅读", "sort": 7},
{"title": "本福特定律:数字会说谎", "question": "财务报表里1开头的数字占比是多少?", "desc": "审计师用这条数学定律抓财务造假,准确率高得惊人。", "detail": "自然界里,首位数字为1的概率约为30%,不是直觉中的1/9。河流长度、人口数量、股票价格——都遵循本福特定律。聪明的会计师如果不知道这条定律,造出的假账一眼就会被看穿。美国国税局就用它来筛选审计目标。", "color": "#F97316", "icon": "🔍", "topic": "统计陷阱", "read_time": "5分钟阅读", "sort": 8},
]
for c_data in cards:
c.execute("""
INSERT INTO knowledge_cards (title, question, description, detail, color, icon, topic, read_time, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (c_data["title"], c_data["question"], c_data["desc"], c_data["detail"],
c_data["color"], c_data["icon"], c_data["topic"], c_data["read_time"], c_data["sort"]))
conn.commit()
conn.close()
print(f"[完成] 已导入 {len(cards)} 条知识卡片")
def load_seed_videos():
"""
从 JSON 文件加载预设视频数据写入数据库。
"""
if not os.path.exists(VIDEOS_PATH):
print(f"[提示] 种子数据文件不存在: {VIDEOS_PATH},跳过导入")
return
with open(VIDEOS_PATH, "r", encoding="utf-8") as f:
videos = json.load(f)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# 重建视频表(确保新增字段生效)
c.execute("DROP TABLE IF EXISTS videos")
c.execute("""
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
module TEXT NOT NULL,
sub_category TEXT,
discipline TEXT,
discipline_icon TEXT,
video_url TEXT,
cover_url TEXT,
description TEXT,
duration_min INTEGER DEFAULT 5,
author TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
for v in videos:
c.execute("""
INSERT INTO videos (title, module, sub_category, discipline, discipline_icon, video_url, cover_url, description, duration_min, author)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (v["title"], v["module"],
v.get("sub_category", ""), v.get("discipline", ""), v.get("discipline_icon", ""),
v.get("video_url", ""), v.get("cover_url", ""),
v.get("description", ""), v.get("duration_min", 5), v.get("author", "")))
conn.commit()
conn.close()
print(f"[完成] 已导入 {len(videos)} 条视频数据")
# ============================================================
# 模块 2: 路由定义(API 接口)
# ============================================================
@app.route("/")
def index():
"""
主页路由 — 返回 SPA(单页应用)的 HTML。
前端所有页面切换都在这一个 HTML 里完成,不刷新浏览器。
"""
return render_template("index.html")
@app.route("/demo")
def demo():
"""返回独立静态 Demo 页面"""
return send_from_directory(BASE_DIR, "mathbrain-demo.html")
@app.route("/web")
def web_desktop():
"""桌面端网页版 — 左侧栏 + 横向布局"""
return render_template("desktop.html")
@app.route("/data/<path:filename>")
def data_files(filename):
"""本地开发时提供 data/ 下的公开 JSON 数据"""
if not filename.lower().endswith(".json"):
return jsonify({"code": -1, "msg": "not allowed"}), 404
return send_from_directory(DATA_DIR, filename)
@app.route("/math_teen")
def math_teen():
"""数学少年线互动剧本独立播放页"""
return render_template("math_teen.html")
@app.route("/api/videos")
def api_videos():
"""
获取视频列表 API。
支持 module 和 discipline 参数筛选:
/api/videos?module=数人
/api/videos?module=数人&discipline=计算机与AI
"""
module = request.args.get("module", "")
discipline = request.args.get("discipline", "")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
query = "SELECT * FROM videos WHERE 1=1"
params = []
if module:
query += " AND module=?"
params.append(module)
if discipline:
query += " AND discipline=?"
params.append(discipline)
query += " ORDER BY module, id DESC"
c.execute(query, params)
rows = [dict(r) for r in c.fetchall()]
# 同时返回当前模块的学科列表(用于前端渲染分类筛选)
if module == "数人":
c.execute("SELECT DISTINCT discipline, discipline_icon FROM videos WHERE module='数人' AND discipline != '' ORDER BY discipline")
disciplines = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify({"code": 0, "data": rows, "disciplines": disciplines})
conn.close()
return jsonify({"code": 0, "data": rows})
@app.route("/api/chat", methods=["POST"])
def api_chat():
"""
AI 对话接口 — 转发用户消息给 DeepSeek,支持上下文感知。
请求格式: { "message": "学数学对学医有什么用?", "context": {"type": "video", "title": "xxx"} }
"""
data = request.get_json()
user_message = data.get("message", "")
student_id = data.get("student_id", "")
context = data.get("context") or {}
ctx_type = context.get("type", "")
ctx_title = context.get("title", "")
if not DEEPSEEK_API_KEY or DEEPSEEK_API_KEY == "your-deepseek-api-key-here":
return jsonify({
"code": -1,
"reply": "DeepSeek API Key 尚未配置,请在 app.py 中填入你的 Key。\n申请地址: https://platform.deepseek.com/",
"is_fallback": True
})
# 根据上下文构建 system prompt
system_prompt = (
"你是'葫芦数学'的AI助教,面向中小学生和数学爱好者。你的核心任务是:\n"
"1. 用生活化的语言解释数学在各学科中的基础作用\n"
"2. 引导学生发现'原来数学这么有用'\n"
"3. 回答尽量简短、有趣,控制在200字以内\n"
"4. 不要出现超出初中生理解范围的专业术语"
)
if ctx_type == "video" and ctx_title:
system_prompt = (
f"用户正在观看视频《{ctx_title}》。你是'葫芦数学'的AI助教。\n"
f"请围绕这个视频主题,用生活化的语言帮助用户理解其中的数学概念。\n"
"回答简短有趣,控制在200字以内。"
)
elif ctx_type == "knowledge" and ctx_title:
system_prompt = (
f"用户正在阅读知识卡片《{ctx_title}》。你是'葫芦数学'的AI助教。\n"
f"请围绕这个主题深入浅出地解释,可以补充有趣的例子和冷知识。\n"
"回答简短有趣,控制在200字以内。"
)
elif ctx_type == "simulator":
system_prompt = (
"用户正在玩'数学专业模拟器'——这是模拟大学数学系4年学习生涯的互动游戏。\n"
"你是'葫芦数学'的AI助教,请扮演大学数学系的学长/学姐角色,\n"
"用鼓励、有趣的方式回答用户关于数学专业的问题。\n"
"可以分享一些大学数学系的真实体验和趣事。回答在200字以内。"
)
try:
resp = requests.post(
DEEPSEEK_API_URL,
headers={
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
"temperature": 0.7,
"max_tokens": 500
},
timeout=15
)
result = resp.json()
reply = result["choices"][0]["message"]["content"]
is_fallback = False
except Exception as e:
reply = f"[助手暂时离线] API 调用失败: {str(e)[:100]}"
is_fallback = True
# 记录行为日志
_log_behavior(student_id, "send_message", detail=user_message)
return jsonify({"code": 0, "reply": reply, "is_fallback": is_fallback, "context_type": ctx_type})
@app.route("/api/log", methods=["POST"])
def api_log():
data = request.json
user_id = data.get("user_id", "guest")
action = data.get("action", "")
target = data.get("target", "")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(
"INSERT INTO behavior_log (user_id, action, target) VALUES (?, ?, ?)",
(user_id, action, target)
)
conn.commit()
conn.close()
return jsonify({"code": 0, "msg": "logged"})
@app.route("/api/students")
def api_students():
"""获取预设学生列表(B端看板用)"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM students ORDER BY grade, class_name")
rows = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify({"code": 0, "data": rows})
@app.route("/api/story")
def api_story():
"""获取数学武林故事数据"""
if not os.path.exists(STORY_PATH):
return jsonify({"code": -1, "msg": "故事数据文件不存在"})
with open(STORY_PATH, "r", encoding="utf-8") as f:
story = json.load(f)
return jsonify({"code": 0, "data": story})
@app.route("/api/story/math_teen")
def api_story_math_teen():
"""获取数学少年线互动剧情数据"""
if not os.path.exists(MATH_TEEN_STORY_PATH):
return jsonify({"code": -1, "msg": "数学少年线故事数据文件不存在"})
with open(MATH_TEEN_STORY_PATH, "r", encoding="utf-8") as f:
story = json.load(f)
return jsonify({"code": 0, "data": story})
@app.route("/api/alumni")
def api_alumni():
"""获取数学校友访谈与四年度模拟预留数据"""
if not os.path.exists(ALUMNI_PATH):
return jsonify({"code": 0, "data": [], "meta": {"status": "reserved"}})
with open(ALUMNI_PATH, "r", encoding="utf-8") as f:
payload = json.load(f)
return jsonify({"code": 0, "data": payload.get("alumni", []), "meta": payload.get("meta", {})})
@app.route("/api/simulator")
def api_simulator():
"""获取数学专业模拟器数据"""
if not os.path.exists(SIMULATOR_PATH):
return jsonify({"code": -1, "msg": "模拟器数据文件不存在"})
with open(SIMULATOR_PATH, "r", encoding="utf-8") as f:
sim = json.load(f)
return jsonify({"code": 0, "data": sim})
@app.route("/api/simulator/save", methods=["POST"])
def api_simulator_save():
"""保存模拟器进度"""
data = request.get_json()
user_id = data.get("user_id", "guest")
save_data = json.dumps(data.get("state", {}))
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS simulator_saves (
user_id TEXT PRIMARY KEY,
state TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
c.execute("INSERT OR REPLACE INTO simulator_saves (user_id, state) VALUES (?, ?)", (user_id, save_data))
conn.commit()
conn.close()
return jsonify({"code": 0, "msg": "saved"})
@app.route("/api/simulator/save", methods=["GET"])
def api_simulator_load():
"""加载模拟器进度"""
user_id = request.args.get("user_id", "guest")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS simulator_saves (user_id TEXT PRIMARY KEY, state TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
c.execute("SELECT state FROM simulator_saves WHERE user_id=?", (user_id,))
row = c.fetchone()
conn.close()
if row:
return jsonify({"code": 0, "data": json.loads(row[0])})
return jsonify({"code": 0, "data": None})
@app.route("/api/knowledge")
def api_knowledge():
"""获取知识卡片列表"""
topic = request.args.get("topic", "")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
query = "SELECT * FROM knowledge_cards WHERE 1=1"
params = []
if topic:
query += " AND topic=?"
params.append(topic)
query += " ORDER BY sort_order"
c.execute(query, params)
rows = [dict(r) for r in c.fetchall()]
# 获取所有话题
c.execute("SELECT DISTINCT topic FROM knowledge_cards WHERE topic != '' ORDER BY topic")
topics = [r["topic"] for r in c.fetchall()]
conn.close()
return jsonify({"code": 0, "data": rows, "topics": topics})
@app.route("/api/knowledge/<int:card_id>")
def api_knowledge_detail(card_id):
"""获取单个知识卡片详情"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM knowledge_cards WHERE id=?", (card_id,))
row = c.fetchone()
conn.close()
if row:
return jsonify({"code": 0, "data": dict(row)})
return jsonify({"code": -1, "msg": "卡片不存在"})
@app.route("/api/favorites", methods=["GET", "POST", "DELETE"])
def api_favorites():
"""收藏管理"""
user_id = request.args.get("user_id") or (request.get_json() or {}).get("user_id", "guest")
if request.method == "GET":
target_type = request.args.get("type", "")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
if target_type:
c.execute("SELECT * FROM favorites WHERE user_id=? AND target_type=? ORDER BY created_at DESC", (user_id, target_type))
else:
c.execute("SELECT * FROM favorites WHERE user_id=? ORDER BY created_at DESC", (user_id,))
rows = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify({"code": 0, "data": rows})
elif request.method == "POST":
data = request.get_json()
target_type = data.get("target_type", "video")
target_id = data.get("target_id", 0)
title = data.get("title", "")
cover = data.get("cover", "")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
c.execute(
"INSERT OR IGNORE INTO favorites (user_id, target_type, target_id, title, cover) VALUES (?, ?, ?, ?, ?)",
(user_id, target_type, target_id, title, cover)
)
conn.commit()
added = c.rowcount > 0
except Exception:
added = False
conn.close()
return jsonify({"code": 0, "msg": "collected" if added else "already_exists"})
elif request.method == "DELETE":
data = request.get_json()
target_type = data.get("target_type", "video")
target_id = data.get("target_id", 0)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("DELETE FROM favorites WHERE user_id=? AND target_type=? AND target_id=?", (user_id, target_type, target_id))
conn.commit()
conn.close()
return jsonify({"code": 0, "msg": "removed"})
@app.route("/api/favorites/check")
def api_favorites_check():
"""批量检查收藏状态"""
user_id = request.args.get("user_id", "guest")
target_type = request.args.get("type", "video")
ids_str = request.args.get("ids", "")
if not ids_str:
return jsonify({"code": 0, "data": {}})
ids = [int(x) for x in ids_str.split(",") if x.strip().isdigit()]
if not ids:
return jsonify({"code": 0, "data": {}})
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
placeholders = ",".join("?" * len(ids))
c.execute(f"SELECT target_id FROM favorites WHERE user_id=? AND target_type=? AND target_id IN ({placeholders})",
[user_id, target_type] + ids)
favorited = {row[0]: True for row in c.fetchall()}
conn.close()
return jsonify({"code": 0, "data": favorited})
@app.route("/api/history", methods=["GET", "POST"])
def api_history():
"""观看历史"""
user_id = request.args.get("user_id") or (request.get_json() or {}).get("user_id", "guest")
if request.method == "GET":
target_type = request.args.get("type", "")
limit = int(request.args.get("limit", 20))
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
if target_type:
c.execute("SELECT * FROM watch_history WHERE user_id=? AND target_type=? ORDER BY watched_at DESC LIMIT ?", (user_id, target_type, limit))
else:
c.execute("SELECT * FROM watch_history WHERE user_id=? ORDER BY watched_at DESC LIMIT ?", (user_id, limit))
rows = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify({"code": 0, "data": rows})
elif request.method == "POST":
data = request.get_json()
target_type = data.get("target_type", "video")
target_id = data.get("target_id", 0)
title = data.get("title", "")
cover = data.get("cover", "")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(
"INSERT INTO watch_history (user_id, target_type, target_id, title, cover) VALUES (?, ?, ?, ?, ?)",
(user_id, target_type, target_id, title, cover)
)
conn.commit()
conn.close()
return jsonify({"code": 0, "msg": "recorded"})
@app.route("/api/user/profile", methods=["GET", "POST"])
def api_user_profile():
"""用户个人资料"""
user_id = request.args.get("user_id") or (request.get_json() or {}).get("user_id", "guest")
if request.method == "GET":
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM users WHERE user_id=?", (user_id,))
row = c.fetchone()
if not row:
c.execute("INSERT INTO users (user_id) VALUES (?)", (user_id,))
conn.commit()
c.execute("SELECT * FROM users WHERE user_id=?", (user_id,))
row = c.fetchone()
conn.close()
return jsonify({"code": 0, "data": dict(row)})
elif request.method == "POST":
data = request.get_json()
nickname = data.get("nickname", "")
avatar_emoji = data.get("avatar_emoji", "")
avatar_color = data.get("avatar_color", "")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO users (user_id) VALUES (?) ON CONFLICT(user_id) DO NOTHING", (user_id,))
if nickname:
c.execute("UPDATE users SET nickname=? WHERE user_id=?", (nickname, user_id))
if avatar_emoji:
c.execute("UPDATE users SET avatar_emoji=? WHERE user_id=?", (avatar_emoji, user_id))
if avatar_color:
c.execute("UPDATE users SET avatar_color=? WHERE user_id=?", (avatar_color, user_id))
conn.commit()
conn.close()
return jsonify({"code": 0, "msg": "updated"})
@app.route("/api/user/stats")
def api_user_stats():
"""用户统计数据"""
user_id = request.args.get("user_id", "guest")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM watch_history WHERE user_id=?", (user_id,))
watch_count = c.fetchone()[0] or 0
c.execute("SELECT COUNT(*) FROM favorites WHERE user_id=?", (user_id,))
fav_count = c.fetchone()[0] or 0
conn.close()
return jsonify({"code": 0, "data": {"watch_count": watch_count, "favorite_count": fav_count}})
# ---- 工具函数 ----
def _log_behavior(student_id, action_type, target_id="", detail=""):
"""内部函数:写行为日志"""
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(
"INSERT INTO behavior_log (user_id, action, target, detail) VALUES (?, ?, ?, ?)",
(student_id, action_type, target_id, detail)
)
conn.commit()
conn.close()
except Exception:
pass # 日志写入失败不阻塞主流程
# ---- 预设学生数据(模拟学校导入) ----
def seed_students():
"""插入预设学生,模拟'三下乡'时学校提供的名单"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM students")
if c.fetchone()[0] == 0:
students = [
("2024001", "张三", "七年级(1)班", "7"),
("2024002", "李四", "七年级(1)班", "7"),
("2024003", "王五", "七年级(2)班", "7"),
("2024004", "赵六", "八年级(1)班", "8"),
("2024005", "陈七", "八年级(1)班", "8"),
("2024006", "刘八", "九年级(1)班", "9"),
]
c.executemany(
"INSERT INTO students (student_id, name, class_name, grade) VALUES (?, ?, ?, ?)",
students
)
conn.commit()
conn.close()
# ============================================================
# MathBTI — 数学人格测试
# ============================================================
@app.route("/mathbti")
def mathbti_page():
"""MathBTI 独立页面 — 可分享链接"""
return render_template("mathbti.html")
@app.route("/api/math_personality")
def api_math_personality():
"""获取数学人格测试题目数据"""
if not os.path.exists(MATHBTI_PATH):
return jsonify({"code": -1, "msg": "测试数据文件不存在"})
with open(MATHBTI_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return jsonify({"code": 0, "data": data})
@app.route("/api/math_personality/calculate", methods=["POST"])
def api_math_personality_calculate():
"""
根据用户答案计算结果
请求格式: { "answers": [0,3,2,1,0,3,2,1,0,3,2,1] } # 12个得分值
返回: { code: 0, data: { binary_code: "0101", result: {...}, axis_scores: {...} } }
"""
req_data = request.get_json()
answers = req_data.get("answers", []) if req_data else []
if not os.path.exists(MATHBTI_PATH):
return jsonify({"code": -1, "msg": "测试数据文件不存在"})
with open(MATHBTI_PATH, "r", encoding="utf-8") as f:
test_data = json.load(f)
if len(answers) != 12:
return jsonify({"code": -1, "msg": "需要12个答案"})
questions = test_data.get("questions", [])
axes = test_data.get("scoring", {}).get("axes", ["style", "purpose", "era", "social"])
cutoff = test_data.get("scoring", {}).get("cutoff", 4)
# 计算每轴总分
axis_scores = {ax: 0 for ax in axes}
for i, q in enumerate(questions):
ax = q.get("axis", "")
score = answers[i] if i < len(answers) else 0
if ax in axis_scores:
axis_scores[ax] += score
# 生成二进制码
binary_code = ""
for ax in axes:
total = axis_scores.get(ax, 0)
binary_code += "1" if total > cutoff else "0"
# 获取结果
results = test_data.get("results", {})
result = results.get(binary_code, results.get("0000", {}))
return jsonify({
"code": 0,
"data": {
"binary_code": binary_code,
"result": result,
"axis_scores": axis_scores
}
})
# ============================================================
# 模块 3: 应用启动入口
# ============================================================
if __name__ == "__main__":
print("=" * 50)
print(" 数学桥梁计划 · MVP 后端启动中...")
print("=" * 50)
init_db()
load_seed_videos()
load_seed_knowledge()
seed_students()
print(f"\n 访问地址: http://127.0.0.1:5000\n")
app.run(debug=False, host="0.0.0.0", port=5000)