""" 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/") 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/") 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)