This repository has been archived on 2026-08-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Hulumath-Web-Demo/app.py
T
2026-08-08 17:40:47 +08:00

827 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, redirect
# ---- 初始化 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(BASE_DIR, "docs", "数学少年线_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():
"""主页路由 — 重定向到桌面端网页版"""
return redirect("/web", code=302)
@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)