feat(database): 迁移用户关联字段并优化打卡系统

- 修改数据库配置将MYSQL_DB从mys_info_v3改为mys_info_test
- 新增auth_users表wechat_openid字段并建立唯一索引
- 为plan_checkins表添加计划性能索引(idx_plan_period_student)
- 将checkin_records_old表关联方式从student_id改为user_id
- 将plan_checkins表关联方式从student_id改为user_id并更新附件路径
- 移除routes/auth.py中的uuid导入使用安全token生成函数
- 重构routes/checkin.py打卡逻辑去除course_type字段并优化返回结构
- 添加打卡时间限制验证功能防止过期打卡操作
- 实现小组统计功能支持老师查看各组打卡情况
- 更新主页index.html界面适配新的打卡和计划功能
- 调整计划提交时间规则为周五至周四周期
- 添加月度和年度计划提交状态验证机制
This commit is contained in:
王宏豪
2026-09-10 14:11:59 +08:00
parent 4fbcff2bb4
commit 9a7681112b
17 changed files with 876 additions and 168 deletions

2
.env
View File

@@ -6,7 +6,7 @@ MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306 MYSQL_PORT=3306
MYSQL_USER=root MYSQL_USER=root
MYSQL_PASSWORD=MingYuan20251112.!@# MYSQL_PASSWORD=MingYuan20251112.!@#
MYSQL_DB=mys_info_v3 MYSQL_DB=mys_info_test
SURVEY_DB=myxhxc SURVEY_DB=myxhxc
PHONE_HMAC_KEY=MingYuan2026AuthKey PHONE_HMAC_KEY=MingYuan2026AuthKey
H5_BASE_URL=https://m.muguaclub.cn H5_BASE_URL=https://m.muguaclub.cn

258
index.html Normal file
View File

@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="pragma" content="no-cache">
<meta http-equiv="expires" content="0">
<title>明缘进阶场 · 薪火相传</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--gold:#C67B30;--gold-light:#E8C99B;--warm-white:#FDFBF7;--ink:#2C1810;--ink-l:#5A4A3A;--fs:"Inter","PingFang SC","Noto Sans SC",sans-serif;--fser:"Noto Serif SC",serif}
html{scroll-behavior:smooth}
body{font-family:var(--fs);background:var(--warm-white);color:var(--ink-l);line-height:1.6;min-height:100vh;display:flex;justify-content:center;background-image:radial-gradient(circle at 10% 20%, rgba(198,123,48,0.06) 0%, transparent 50%),radial-gradient(circle at 90% 80%, rgba(192,57,43,0.04) 0%, transparent 50%)}
.wrap{max-width:960px;width:100%;margin:0 auto;padding:28px 24px 20px;display:flex;flex-direction:column;min-height:100vh}
header{text-align:center;margin-bottom:32px;padding:20px 0 16px;position:relative}
header::after{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:80px;height:2px;background:linear-gradient(90deg,transparent,var(--gold),transparent)}
header h1{font-family:var(--fser);font-size:40px;font-weight:900;color:var(--ink);margin-bottom:6px;letter-spacing:8px;text-shadow:0 2px 4px rgba(0,0,0,0.02)}
header .greeting-wrap{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:10px;flex-wrap:wrap}
header .greeting-wrap .name{font-size:20px;font-weight:700;color:var(--gold)}
header .greeting-wrap .msg{font-size:14px;color:#888;background:rgba(198,123,48,0.08);padding:2px 14px;border-radius:20px;border:1px solid rgba(198,123,48,0.12)}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px;align-content:start}
.choice{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding:20px 22px;border-radius:16px;background:rgba(255,255,255,0.75);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);text-decoration:none;transition:all 0.35s cubic-bezier(0.25,0.46,0.45,0.94);gap:8px;box-shadow:0 4px 20px rgba(0,0,0,0.04),0 1px 3px rgba(0,0,0,0.02);border:1px solid rgba(255,255,255,0.9);min-height:120px;position:relative;overflow:hidden}
.choice::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,transparent 60%,rgba(198,123,48,0.03));pointer-events:none;border-radius:16px}
.choice:hover{transform:translateY(-4px);box-shadow:0 20px 40px rgba(44,24,16,0.08),0 4px 12px rgba(198,123,48,0.10);border-color:rgba(198,123,48,0.3);background:rgba(255,255,255,0.92)}
.choice .row{display:flex;align-items:center;gap:14px;width:100%}
.choice .icon{font-size:34px;flex-shrink:0;line-height:1;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.04));margin-bottom:2px}
.choice .label{font-size:18px;font-weight:700;color:var(--ink);letter-spacing:1px;transition:color .2s}
.choice:hover .label{color:var(--gold)}
.choice .desc{font-size:13px;color:#999;line-height:1.4;letter-spacing:0.3px}
.choice .badge{display:inline-block;background:rgba(192,57,43,0.06);color:#c0392b;font-size:11px;padding:0 10px;border-radius:12px;font-weight:500;border:1px solid rgba(192,57,43,0.08);margin-left:8px;line-height:20px}
.choice.virtual{background:linear-gradient(135deg,#fdfcf9,#f7f0e6);border:1px dashed rgba(198,123,48,0.3)}
.choice.virtual:hover{border-color:var(--gold);background:#fcf8f0}
.choice.hidden{display:none}
.bottom-links{text-align:center;padding:18px 0 4px;font-size:13px;color:#bbb;border-top:1px solid rgba(0,0,0,0.04);margin-top:auto}
.bottom-links a{color:#8B6B4A;text-decoration:none;margin:0 8px;transition:color .2s;font-weight:450}
.bottom-links a:hover{color:var(--gold);text-decoration:none}
.bottom-links .sep{color:#ddd;margin:0 2px}
.bottom-links .ver{color:#ccc;font-size:12px;margin-left:8px}
@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:550px){.grid{grid-template-columns:1fr}.choice{min-height:auto;padding:16px 18px}.choice .icon{font-size:28px}.choice .label{font-size:16px}.wrap{padding:16px 14px}header h1{font-size:30px;letter-spacing:4px}}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>明缘进阶场</h1>
<div class="greeting-wrap">
<span class="name" id="welcome">加载中...</span>
<span class="msg" id="greeting"></span>
<span class="enc-line" id="encLine" style="font-size:13px;color:#8B6B4A;margin-top:4px;font-style:italic;opacity:0.85;display:block"></span><p class="enc-line" id="encLine" style="font-size:13px;color:#8B6B4A;margin-top:6px;font-style:italic;opacity:0.85"></p>
</div>
</header>
<div class="grid" id="grid">
<a href="/xinhuo/" class="choice" id="btn_xh">
<div class="row">
<div class="icon">🔥</div>
<div>
<div class="label">薪火相传</div>
<div class="desc">年课 / 终身学习小组</div>
</div>
</div>
</a>
<a href="/19qi/" class="choice hidden" id="btn_19">
<div class="row">
<div class="icon">📖</div>
<div>
<div class="label">19期研修</div>
<div class="desc">小班课 · 第19期</div>
</div>
</div>
</a>
<a href="/18qi/" class="choice hidden" id="btn_18">
<div class="row">
<div class="icon">📖</div>
<div>
<div class="label">18期研修</div>
<div class="desc">小班课 · 第18期</div>
</div>
</div>
</a>
<a href="/20qi/" class="choice hidden" id="btn_20">
<div class="row">
<div class="icon">📖</div>
<div>
<div class="label">20期小班课</div>
<div class="desc">小班课 · 第20期</div>
</div>
</div>
</a>
<a href="/xinhuo/upgrade-info.html" class="choice" id="btn_teacher">
<div class="row">
<div class="icon">🎓</div>
<div>
<div class="label">老师大班授权</div>
<div class="desc">年课 / 长期学员入口</div>
</div>
</div>
</a>
</a>
<a href="/xinhuo360/" class="choice" id="btn_360"><div class="row"><div class="icon">🧬</div><div><div class="label">学员360</div><div class="desc">薪火相传 · 个人成长档案</div></div></div></a><a href="/quant-courses.html" class="choice" id="btn_quant"><div class="row"><div class="icon">📊</div><div><div class="label">量化课程</div><div class="desc">量化课程内容 · 随课程方上传更新</div></div></div></a><a href="/reading-club.html" class="choice" id="btn_reading"><div class="row"><div class="icon">📚</div><div><div class="label">读书会</div><div class="desc">经典原著 · 共读课程</div></div></div></a><a href="/course-assistant.html" class="choice hidden" id="btn_assistant"><div class="row"><div class="icon">🎓</div><div><div class="label">课程助理</div><div class="desc">老师专属 · 逐页讲解</div></div></div></a>
<a href="/xinhuo/virtual-group.html" class="choice virtual" id="btn_demo">
<div class="row">
<div class="icon">🧭</div>
<div>
<div class="label">功能演示</div>
<div class="desc">先看看讨论和量化报告的样子</div>
</div>
</div>
</a>
<a href="yijing/" class="choice" id="btn_yijing"><div class="row"><div class="icon">☯</div><div><div class="label">易经学习</div><div class="desc">周易六十四卦 · 经文详解</div></div></div></a>
<a href="homework/" class="choice hidden" id="btn_hw"><div class="row"><div class="icon">📝</div><div><div class="label">20期作业评价</div><div class="desc">回嗔作喜 · 观过思仁 · 温故知新</div></div></div></a>
<a href="checkin/index.html" class="choice" id="btn_checkin">
<div class="row">
<div class="icon">&#128203;</div>
<div>
<div class="label">上课打卡</div>
<div class="desc">会员课程 · 小组讨论考勤</div>
</div>
</div>
</a>
<a href="plan/index.html" class="choice" id="btn_plan">
<div class="row">
<div class="icon">&#9989;</div>
<div>
<div class="label">计划打卡</div>
<div class="desc">周计划 · 月计划 · 年计划</div>
</div>
</div>
</a>
</div>
<div class="bottom-links">
<a href="/feedback.html">问题反馈</a><span class="sep">|</span>
<a href="/changelog.html">更新日志</a><span class="sep">|</span>
<a href="/help.html">使用帮助</a><span class="sep">|</span>
<a href="#" id="logoutBtn">退出登录</a>
<span class="ver" id="versionInfo">V3.1</span>
</div>
</div>
<script>
(async function(){
try{
var vr=await fetch("/api/auth/verify");var vd=await vr.json();
if(!vd.success){window.location.href="/auth/login.html";return;}
var myName=vd.name||"";var displayName=vd.display_name||myName;var myRole=vd.role||"student";var isTeacher=(myRole==="teacher");var sid=vd.student_id||"";
document.getElementById("welcome").textContent=displayName||"欢迎回来";
var h=new Date().getHours();var greet="";
if(h<6)greet="夜深了,还在用功";
else if(h<9)greet="早上好,今天又起了一个大早";
else if(h<12)greet="上午好";
else if(h<14)greet="中午好,记得吃饭";
else if(h<18)greet="下午好";
else if(h<21)greet="晚上好";
else greet="夜深了,早点休息";
document.getElementById("greeting").textContent=greet;
var ps=[{n:"薪火相传",b:document.getElementById("btn_xh"),d:"xinhuo"},{n:"19期",b:document.getElementById("btn_19"),d:"19qi"},{n:"18期",b:document.getElementById("btn_18"),d:"18qi"},{n:"20期",b:document.getElementById("btn_20"),d:"20qi"}];
ps.forEach(function(p){
var mg=null;
if(vd.class_groups){for(var j=0;j<vd.class_groups.length;j++){if(vd.class_groups[j].period===p.n){mg=vd.class_groups[j];break;}}}
if(!mg&&p.n==="薪火相传"&&vd.group){mg={group:vd.group,period:"薪火相传",role:"组员"};}
if(isTeacher){p.b.classList.remove("hidden");}
else if(p.n==="薪火相传"&&(sid.indexOf("N")===0||sid.indexOf("0")===0)){p.b.classList.remove("hidden");p.b.href="/xinhuo/index.html";}
else if(mg){p.b.classList.remove("hidden");var g=(mg.group||"").replace("组","").trim();if(g.length===1)g="0"+g;if(/^\d+$/.test(g)){p.b.href="/"+p.d+"/"+"No"+parseInt(g,10)+".html";}else{p.b.href="/"+p.d+"/free-group.html";}}
else{p.b.classList.add("hidden");}
});
var hwBtn=document.getElementById("btn_hw");
if(hwBtn){
var is20=false;
if(vd.class_groups){for(var j=0;j<vd.class_groups.length;j++){if(vd.class_groups[j].period==="20期"){is20=true;break;}}}
if(isTeacher||is20){hwBtn.classList.remove("hidden");}
}
var assistBtn=document.getElementById("btn_assistant");
if(assistBtn){
if(isTeacher){assistBtn.classList.remove("hidden");}
else{assistBtn.classList.add("hidden");}
}
var bt=document.getElementById("btn_teacher");
if(bt){
if(isTeacher||sid.startsWith("N")||sid.startsWith("0")){bt.href="/teacher/";}
else{bt.href="/xinhuo/upgrade-info.html";}
}
// 功能演示:仅自由学习组/周计划组成员可见(自由学习组含薪火相传与小班课)
var demoBtn=document.getElementById("btn_demo");
if(demoBtn){
var isFreeOrPlan=false;
if(vd.class_groups){for(var j=0;j<vd.class_groups.length;j++){
var gg=(vd.class_groups[j].group||"");
if(gg==="自由学习组"||gg==="自由"||gg==="周计划"){isFreeOrPlan=true;break;}
}}
if(isTeacher||isFreeOrPlan){demoBtn.classList.remove("hidden");}
else{demoBtn.classList.add("hidden");}
}
// 学员360:老师进索引,学生直接进自己的报告/吸引力页
var b360=document.getElementById("btn_360");
if(b360){
if(isTeacher){b360.href="/xinhuo360/index.html";}
else{
fetch("/xinhuo360/_user360_versions.json?t="+Date.now(),{cache:"no-store"})
.then(function(r){return r.json();})
.then(function(d){
var ks=Object.keys(d.versions||{});
for(var i=0;i<ks.length;i++){if(ks[i].indexOf(sid+"-")===0){b360.href="/xinhuo360/"+ks[i]+"?t="+Date.now();return;}}
}).catch(function(){});
}
}
// Random encouragement
var periods = ["xinhuo","19qi","18qi","20qi"];
var rp = periods[Math.floor(Math.random()*periods.length)];
var encUrl = "";
var grp = vd.group || "";
if (grp === "自由学习组" || grp === "周计划") {
encUrl = "/" + rp + "/_encouragements_free.json?t=" + Date.now();
} else if (isTeacher) {
var rg = ("0"+Math.floor(Math.random()*33+1)).slice(-2);
encUrl = "/xinhuo/reports/" + rg + "组/_encouragements.json?t=" + Date.now();
} else {
var ownG = (grp||"").replace("组","").trim();
if (ownG && /^\d+$/.test(ownG)) {
if (ownG.length===1) ownG="0"+ownG;
encUrl = "/" + rp + "/reports/" + ownG + "组/_encouragements.json?t=" + Date.now();
}
}
if (encUrl) {
fetch(encUrl).then(function(r){return r.ok?r.json():null}).then(function(d){
if(!d) return;
var pool = (d.team||[]).slice();
if (d.intro) pool = pool.concat(d.intro);
var myMsgs = (d.members||{})[myName] || (d.members||{})[sid] || [];
pool = pool.concat(myMsgs);
if (pool.length) {
var msg = pool[Math.floor(Math.random()*pool.length)];
var memberNames = Object.keys(d.members || {});
for (var ni = 0; ni < memberNames.length; ni++) {
if (msg.indexOf(memberNames[ni]) >= 0) { msg = '有同学说:' + msg; break; }
}
document.getElementById("encLine").textContent = msg;
}
}).catch(function(){});
}
document.getElementById("logoutBtn").onclick=function(e){e.preventDefault();window.location.href="/api/auth/logout?redirect=/auth/login.html";};
}catch(e){window.location.href="/auth/login.html";}
})();
</script>
</body>
</html>

View File

@@ -5,11 +5,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI from fastapi import FastAPI
from server_prd.routes import auth, checkin, course_vote, feedback, pages, plan, profile, survey, webview, yijing from server.routes import auth, checkin, course_vote, feedback, plan, profile, survey, webview, yijing
app = FastAPI(title="明缘进阶场 API") app = FastAPI(title="明缘进阶场 API")
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(pages.router)
app.include_router(checkin.router) app.include_router(checkin.router)
app.include_router(plan.router) app.include_router(plan.router)
app.include_router(profile.router) app.include_router(profile.router)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
ALTER TABLE `auth_users`
ADD COLUMN `wechat_openid` VARCHAR(64) NULL DEFAULT NULL,
ADD UNIQUE KEY `uk_auth_users_wechat_openid` (`wechat_openid`);

View File

@@ -0,0 +1,4 @@
-- 002_plan_perf_index.sql
-- 计划小组总览按 (plan_type, period_key, student_id) 扫描,补齐索引以加速聚合查询。
ALTER TABLE `plan_checkins`
ADD INDEX `idx_plan_period_student` (`plan_type`, `period_key`, `student_id`);

View File

@@ -0,0 +1,35 @@
-- =========================================================
-- 003: 打卡记录改为按 user_id 关联(user_id = auth_users.id)
-- 目标库:mys_info_test(正式库:mys_info,如需可改)
-- 前置:checkin_records_old 已有按 student_id 关联的旧数据
-- 注意:执行前请先备份 checkin_records_old 表
-- =========================================================
-- 1) 新增 user_id 列
ALTER TABLE `checkin_records_old`
ADD COLUMN `user_id` BIGINT NULL DEFAULT NULL AFTER `id`;
-- 2) 用 auth_users.id 按旧 student_id 回填历史记录的 user_id
UPDATE `checkin_records_old` r
LEFT JOIN `auth_users` u ON u.`student_id` = r.`student_id`
SET r.`user_id` = u.`id`
WHERE r.`user_id` IS NULL;
-- 3) 确认旧 student_id 允许为 NULL
-- 新打卡代码只写 user_id,不再写 student_id
-- ⚠ 若原 student_id 实际类型不是 VARCHAR(64),请改成与原列一致的类型
ALTER TABLE `checkin_records_old`
MODIFY COLUMN `student_id` VARCHAR(64) NULL DEFAULT NULL;
-- 4) 唯一键改为 (user_id, class_date),供同用户同一天 upsert 使用
ALTER TABLE `checkin_records_old`
ADD UNIQUE KEY `uk_checkin_user_date` (`user_id`, `class_date`);
-- =========================================================
-- 以下为可选清理(按实际索引名执行)
-- 5) 旧版若存在 (student_id, class_date) 唯一键,确认不再需要后删除:
-- ALTER TABLE `checkin_records_old` DROP INDEX `uk_checkin_student_date`;
-- 6) 数据质量核查:检查是否有 student_id 无对应用户、user_id 仍为空的历史记录:
-- SELECT * FROM checkin_records_old WHERE user_id IS NULL;
-- =========================================================

View File

@@ -0,0 +1,47 @@
-- =========================================================
-- 004: 计划记录改为按 user_id 关联(user_id = auth_users.id)
-- 目标库:mys_info_test(正式库:mys_info,如需可改)
-- 前置:plan_checkins 已有按 student_id 关联的旧数据
-- 注意:执行前请先备份 plan_checkins 表
-- =========================================================
-- 1) 新增 user_id 列
ALTER TABLE `plan_checkins`
ADD COLUMN `user_id` BIGINT NULL DEFAULT NULL AFTER `id`;
-- 2) 用 auth_users.id 按旧 student_id 回填历史记录的 user_id
UPDATE `plan_checkins` r
LEFT JOIN `auth_users` u ON u.`student_id` = r.`student_id`
SET r.`user_id` = u.`id`
WHERE r.`user_id` IS NULL;
-- 3) 重写历史附件相对路径:attachments/<student_id>/... -> attachments/<user_id>/...
UPDATE `plan_checkins` r
JOIN `auth_users` u ON u.`student_id` = r.`student_id`
SET r.`attachments` = REPLACE(r.`attachments`,
CONCAT('attachments/', r.`student_id`, '/'),
CONCAT('attachments/', u.`id`, '/'))
WHERE r.`student_id` IS NOT NULL AND r.`user_id` IS NOT NULL;
-- 4) 旧 student_id 允许为空;新代码只写 user_id
ALTER TABLE `plan_checkins`
MODIFY COLUMN `student_id` VARCHAR(64) NULL DEFAULT NULL;
-- 5) 新增唯一键 (user_id, plan_type, period_key):同用户同周期 upsert
ALTER TABLE `plan_checkins`
ADD UNIQUE KEY `uk_plan_user_period` (`user_id`, `plan_type`, `period_key`);
-- 6) 计划概览聚合索引改为按 user_id
ALTER TABLE `plan_checkins`
ADD INDEX `idx_plan_period_user` (`plan_type`, `period_key`, `user_id`);
-- =========================================================
-- 以下为可选清理(按实际索引名执行)
-- 7) 旧版 (student_id, plan_type, period_key) 唯一键确认不再使用后:
-- ALTER TABLE `plan_checkins` DROP INDEX `uk_plan_student_period`;
-- 8) 旧版展平索引确认不再使用后:
-- ALTER TABLE `plan_checkins` DROP INDEX `idx_plan_period_student`;
-- 9) 数据质量核查:有无 student_id 无对应用户、user_id 仍为空的历史记录:
-- SELECT * FROM plan_checkins WHERE user_id IS NULL;
-- =========================================================

View File

@@ -1,5 +1,4 @@
import logging import logging
import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@@ -53,7 +52,7 @@ def get_current_user(request: Request):
def _record_session(user_id): def _record_session(user_id):
"""创建会话 token 并写入 auth_sessions。""" """创建会话 token 并写入 auth_sessions。"""
token = uuid.uuid4().hex + uuid.uuid4().hex[:16] token = security.create_session_token()
expires = datetime.now() + timedelta(seconds=config.JWT_TTL_SECONDS) expires = datetime.now() + timedelta(seconds=config.JWT_TTL_SECONDS)
db.execute( db.execute(
"INSERT INTO auth_sessions (user_id, token, expires_at) VALUES (%s, %s, %s)", "INSERT INTO auth_sessions (user_id, token, expires_at) VALUES (%s, %s, %s)",
@@ -122,4 +121,3 @@ def logout(request: Request, user=Depends(get_current_user)):
# 同时清除 webview cookie 登录态 # 同时清除 webview cookie 登录态
resp.delete_cookie(key="auth_token", path="/") resp.delete_cookie(key="auth_token", path="/")
return resp return resp

View File

@@ -15,15 +15,51 @@ router = APIRouter(prefix="/api/v1/checkin", tags=["checkin"])
CHECKIN_STATUSES = {"正常", "迟到", "请假", "不参加讨论", "早退"} CHECKIN_STATUSES = {"正常", "迟到", "请假", "不参加讨论", "早退"}
DEFAULT_STATUS = "正常" DEFAULT_STATUS = "正常"
DEFAULT_COURSE_TYPE = "会员课程"
MAX_NOTE_LEN = 500 MAX_NOTE_LEN = 500
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SELECT_COLS = "id,user_id,class_date,checked_at,status,course_type,note" SELECT_COLS = "id,user_id,class_date,checked_at,status,note"
# 周期列表起始年份
PERIOD_START_YEAR = 2022
def get_class_date(): def get_class_date(ref_date=None):
"""获取指定日期所在周的周六日期(上课日)。"""
today = ref_date or date.today()
return today + timedelta(days=(5 - today.weekday()))
def get_saturday_of_week(week_monday: date) -> date:
"""给定某周周一,返回该周周六。"""
return week_monday + timedelta(days=5)
def generate_week_list(start_year: int = PERIOD_START_YEAR) -> list:
"""
生成从 start_year 到今天的所有上课周(每周六)列表,倒序排列。
返回格式: ["2026-09-19", "2026-09-12", ...]
"""
today = date.today() today = date.today()
return today + timedelta(days=(6 - today.weekday())) # 找到 start_year 第一个周六
# 从1月1日往前找最近的周六,再往后逐周生成
d = date(start_year, 1, 1)
# 调整到第一个周六
days_until_saturday = (5 - d.weekday()) % 7
if days_until_saturday == 0 and d.weekday() != 5:
days_until_saturday = 0
first_saturday = d + timedelta(days=days_until_saturday)
# 确保第一个周六在 start_year 内
if first_saturday.year < start_year:
first_saturday += timedelta(days=7)
weeks = []
current = first_saturday
while current <= today + timedelta(days=7): # 多生成一周
weeks.append(current.strftime("%Y-%m-%d"))
current += timedelta(days=7)
weeks.reverse() # 倒序,最新的在前
return weeks
def _is_valid_date(value): def _is_valid_date(value):
@@ -42,29 +78,94 @@ def fmt_dt(value):
return str(value) if value else "" return str(value) if value else ""
def fmt_rec(rec, name=""): def fmt_rec(rec):
return { return {
"id": rec["id"], "id": rec["id"],
"user_id": rec["user_id"], "user_id": rec["user_id"],
"name": name,
"class_date": str(rec["class_date"]), "class_date": str(rec["class_date"]),
"checked_at": fmt_dt(rec.get("checked_at")), "checked_at": fmt_dt(rec.get("checked_at")),
"status": rec.get("status", DEFAULT_STATUS), "status": rec.get("status", DEFAULT_STATUS),
"course_type": rec.get("course_type", DEFAULT_COURSE_TYPE),
"note": rec.get("note", ""), "note": rec.get("note", ""),
} }
def _is_teacher(user):
"""判断当前用户是否为老师角色。"""
return (user.get("role") or "").strip().lower() == "teacher"
def _groups_stats(class_date, group_filter=None):
"""
统计各小组在指定上课日的打卡情况。
返回 [{group_name, checked_count, total}],按 group_name 排序。
group_filter: 只统计指定小组
"""
where_clauses = []
params = []
if group_filter:
where_clauses.append("m.`group` = %s")
params.append(group_filter)
else:
where_clauses.append("m.`group` IS NOT NULL AND m.`group` != ''")
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
rows = db.fetch_all(
"SELECT m.`group` AS group_name, "
" COUNT(*) AS total, "
" SUM(CASE WHEN r.id IS NOT NULL THEN 1 ELSE 0 END) AS checked_count "
"FROM auth_users m "
"LEFT JOIN checkin_records_old r "
" ON r.user_id = m.id AND r.class_date = %s "
+ where_sql + " "
"GROUP BY m.`group` "
"ORDER BY m.`group`",
(class_date, *params),
)
return [
{
"group_name": r["group_name"] or "",
"total": r["total"] or 0,
"checked_count": r["checked_count"] or 0,
}
for r in rows
]
class CheckinRequest(BaseModel): class CheckinRequest(BaseModel):
status: str = DEFAULT_STATUS status: str = DEFAULT_STATUS
class_date: str = "" class_date: str = ""
note: str = "" note: str = ""
course_type: str = DEFAULT_COURSE_TYPE
@router.get("/weeks")
def weeks(user=Depends(get_current_user)):
"""
返回所有上课周列表(每周六日期),从 2022 年到当前,倒序排列。
用于前端做历史周切换选择器。
"""
week_list = generate_week_list()
current = str(get_class_date())
return {
"weeks": week_list,
"current_week": current,
}
@router.get("/week") @router.get("/week")
def week(user=Depends(get_current_user)): def week(class_date: str = "", user=Depends(get_current_user)):
class_date = str(get_class_date()) """
[已弃用] 获取指定上课日的个人打卡状态。
请使用 /groups 接口,其返回值已包含 checked + record。
保留此接口仅为兼容旧客户端。
"""
if class_date:
class_date = class_date.strip()
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
else:
class_date = str(get_class_date())
rec = db.fetch_one( rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s", "SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date), (user["id"], class_date),
@@ -72,6 +173,83 @@ def week(user=Depends(get_current_user)):
return {"class_date": class_date, "checked": rec is not None, "record": fmt_rec(rec) if rec else None} return {"class_date": class_date, "checked": rec is not None, "record": fmt_rec(rec) if rec else None}
@router.get("/groups")
def groups_view(class_date: str = "", user=Depends(get_current_user)):
"""
老师:返回所有小组统计 + 个人状态
学员:返回自己所在小组统计 + 个人状态
"""
if class_date:
class_date = class_date.strip()
if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期")
else:
class_date = str(get_class_date())
# 个人打卡状态
rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
(user["id"], class_date),
)
my_checked = rec is not None
my_record = fmt_rec(rec) if rec else None
is_teacher = _is_teacher(user)
my_group = (user.get("group") or "").strip()
if is_teacher:
groups_list = _groups_stats(class_date)
# 自己所在小组排最前
if my_group:
mine = [g for g in groups_list if g["group_name"] == my_group]
others = [g for g in groups_list if g["group_name"] != my_group]
groups_list = mine + others
else:
# 学员只看自己小组
if not my_group:
groups_list = []
else:
groups_list = _groups_stats(class_date, group_filter=my_group)
# 未分组人数(仅老师可见)
unassigned_count = 0
if is_teacher:
unassigned_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
)
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
return {
"class_date": class_date,
"checked": my_checked,
"record": my_record,
"is_teacher": is_teacher,
"my_group": my_group,
"groups": groups_list,
"unassigned_count": unassigned_count,
}
def _can_checkin_date(class_date_str):
"""判断指定上课日是否还可以打卡。
规则:上课日所在自然周(周一~周日)与当前周相同,或为未来周,则可以打卡。
上课日是周日,同一自然周内(周一到周日)都可以打卡、可以修改。
"""
try:
class_date = datetime.strptime(class_date_str, "%Y-%m-%d").date()
except ValueError:
return False, "无效的日期"
today = date.today()
# 找到上课日所在周的周一
class_monday = class_date - timedelta(days=class_date.isoweekday() - 1)
# 找到今天所在周的周一
today_monday = today - timedelta(days=today.isoweekday() - 1)
if class_monday < today_monday:
return False, "已过打卡时间"
return True, ""
@router.post("") @router.post("")
def checkin(req: CheckinRequest, user=Depends(get_current_user)): def checkin(req: CheckinRequest, user=Depends(get_current_user)):
status = req.status.strip() status = req.status.strip()
@@ -82,6 +260,10 @@ def checkin(req: CheckinRequest, user=Depends(get_current_user)):
if not _is_valid_date(class_date): if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期") raise HTTPException(status_code=400, detail="无效的上课日期")
can_submit, msg = _can_checkin_date(class_date)
if not can_submit:
raise HTTPException(status_code=400, detail=msg)
note = req.note.strip() if status != DEFAULT_STATUS else "" note = req.note.strip() if status != DEFAULT_STATUS else ""
if len(note) > MAX_NOTE_LEN: if len(note) > MAX_NOTE_LEN:
raise HTTPException(status_code=400, detail="备注过长") raise HTTPException(status_code=400, detail="备注过长")
@@ -94,21 +276,26 @@ def checkin(req: CheckinRequest, user=Depends(get_current_user)):
) )
if existing: if existing:
db.execute( db.execute(
"UPDATE checkin_records_old SET checked_at=%s, status=%s, course_type=%s, note=%s WHERE id=%s", "UPDATE checkin_records_old SET checked_at=%s, status=%s, note=%s WHERE id=%s",
(now, status, DEFAULT_COURSE_TYPE, note, existing["id"]), (now, status, note, existing["id"]),
) )
record_id = existing["id"]
else: else:
# course_type 固定为会员课程(规范),忽略客户端传入值 record_id = db.execute(
db.execute( "INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, note) "
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, course_type, note) " "VALUES (%s, %s, %s, %s, %s)",
"VALUES (%s, %s, %s, %s, %s, %s)", (user["id"], class_date, now, status, note),
(user["id"], class_date, now, status, DEFAULT_COURSE_TYPE, note),
) )
rec = db.fetch_one( # 直接构造返回,省一次 SELECT
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s", record = {
(user["id"], class_date), "id": record_id,
) "user_id": user["id"],
return {"success": True, "record": fmt_rec(rec, user.get("name", "")) if rec else None} "class_date": class_date,
"status": status,
"checked_at": fmt_dt(now),
"note": note,
}
return {"success": True, "record": record}
@router.get("/list") @router.get("/list")
@@ -125,24 +312,42 @@ def list_records(page: int = 1, size: int = 10, user=Depends(get_current_user)):
"WHERE user_id=%s ORDER BY class_date DESC LIMIT %s OFFSET %s", "WHERE user_id=%s ORDER BY class_date DESC LIMIT %s OFFSET %s",
(user["id"], size, offset), (user["id"], size, offset),
) )
return {"list": [fmt_rec(r, user.get("name", "")) for r in rows], "total": total, "page": page, "size": size} return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
@router.get("/group") @router.get("/group")
def group_view(class_date: str = "", user=Depends(get_current_user)): def group_view(class_date: str = "", group_name: str = "", user=Depends(get_current_user)):
"""
查看某小组的打卡成员列表。
- 老师:可以传 group_name 查看任意小组;不传则看自己所在小组
- 学员:只能看自己所在小组,传其他小组名返回 403
"""
class_date = class_date.strip() or str(get_class_date()) class_date = class_date.strip() or str(get_class_date())
if not _is_valid_date(class_date): if not _is_valid_date(class_date):
raise HTTPException(status_code=400, detail="无效的上课日期") raise HTTPException(status_code=400, detail="无效的上课日期")
user_row = db.fetch_one("SELECT `group` FROM auth_users WHERE id=%s", (user["id"],)) my_group = (user.get("group") or "").strip()
if not user_row or not user_row.get("group"): is_teacher = _is_teacher(user)
if group_name:
group_name = group_name.strip()
# 权限校验:学员只能看自己小组
if not is_teacher and group_name != my_group:
raise HTTPException(status_code=403, detail="无权限查看其他小组")
else:
group_name = my_group
if not group_name:
return {"class_date": class_date, "group_name": "", "members": []} return {"class_date": class_date, "group_name": "", "members": []}
group_name = user_row["group"] # 校验小组是否存在
g_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
if not g_row:
return {"class_date": class_date, "group_name": group_name, "members": []}
# 单查询:成员 + 同组成员该上课日的打卡记录,LEFT JOIN 一次取回 # 单查询:成员 + 同组成员该上课日的打卡记录,LEFT JOIN 一次取回
rows = db.fetch_all( rows = db.fetch_all(
"SELECT m.id AS user_id, m.student_id, m.name, " "SELECT m.id AS user_id, m.student_id, m.name, "
" r.status, r.checked_at, r.note " " r.id AS record_id, r.status, r.checked_at, r.note "
"FROM auth_users m " "FROM auth_users m "
"LEFT JOIN checkin_records_old r " "LEFT JOIN checkin_records_old r "
" ON r.user_id = m.id AND r.class_date = %s " " ON r.user_id = m.id AND r.class_date = %s "
@@ -156,6 +361,8 @@ def group_view(class_date: str = "", user=Depends(get_current_user)):
rec = None rec = None
if row.get("status") is not None: if row.get("status") is not None:
rec = { rec = {
"id": row["record_id"],
"user_id": row["user_id"],
"status": row["status"], "status": row["status"],
"checked_at": fmt_dt(row.get("checked_at")), "checked_at": fmt_dt(row.get("checked_at")),
"note": row.get("note", ""), "note": row.get("note", ""),
@@ -164,7 +371,6 @@ def group_view(class_date: str = "", user=Depends(get_current_user)):
"user_id": row["user_id"], "user_id": row["user_id"],
"student_id": row["student_id"], "student_id": row["student_id"],
"name": row["name"], "name": row["name"],
"role": "",
"checked": rec is not None, "checked": rec is not None,
"record": rec, "record": rec,
}) })

View File

@@ -56,7 +56,9 @@ def parse_iso_week(period_key):
def default_weekly_period(today=None): def default_weekly_period(today=None):
today = today or date.today() today = today or date.today()
if today.weekday() >= 1: # 周五及以后(周五、周六、周日)默认填写下周计划
# 周一到周四默认填写本周计划
if today.weekday() >= 4:
return iso_week_key(today + timedelta(days=7)) return iso_week_key(today + timedelta(days=7))
return iso_week_key(today) return iso_week_key(today)
@@ -65,8 +67,10 @@ def weekly_window(period_key):
monday = parse_iso_week(period_key) monday = parse_iso_week(period_key)
if not monday: if not monday:
return None, None return None, None
# 开启:目标周的上一周周五 00:00
open_at = datetime.combine(monday - timedelta(days=3), time.min) open_at = datetime.combine(monday - timedelta(days=3), time.min)
deadline = datetime.combine(monday + timedelta(days=1), time(23, 59, 59)) # 截止:目标周的周四 23:59:59
deadline = datetime.combine(monday + timedelta(days=3), time(23, 59, 59))
return open_at, deadline return open_at, deadline
@@ -78,10 +82,87 @@ def weekly_submit_state(period_key):
if now < open_at: if now < open_at:
return False, "周五开始填写" return False, "周五开始填写"
if now > deadline: if now > deadline:
return False, "已过截止时间" return False, "周四截止,已过提交时间"
return True, "" return True, ""
PERIOD_START_YEAR = 2022
def monthly_submit_state(period_key):
"""月计划提交状态:当前月内可提交,历史月/未来月均不可提交。"""
if not _validate_month_key(period_key):
return False, "无效的月周期"
today = date.today()
year, month = period_key.split("-")
period_year, period_month = int(year), int(month)
if period_year < today.year or (period_year == today.year and period_month < today.month):
return False, "已过提交时间"
if period_year > today.year or (period_year == today.year and period_month > today.month):
return False, "未到提交时间"
return True, ""
def yearly_submit_state(period_key):
"""年计划提交状态:当前年内可提交,往年/未来年均不可提交。"""
if not _validate_year_key(period_key):
return False, "无效的年周期"
today = date.today()
period_year = int(period_key)
if period_year < today.year:
return False, "已过提交时间"
if period_year > today.year:
return False, "未到提交时间"
return True, ""
def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR):
"""
生成从 start_year 到当前的所有自然周期列表,倒序排列(最新在前)。
- weekly: ISO 周格式 YYYY-Www
- monthly: YYYY-MM
- yearly: YYYY
"""
today = date.today()
if plan_type == "weekly":
weeks = []
# 从 start_year 第一个 ISO 周开始
jan4 = date(start_year, 1, 4)
first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
# 截止到当前 ISO 周
iso_today = today.isocalendar()
last_jan4 = date(iso_today[0], 1, 4)
last_monday = last_jan4 - timedelta(days=last_jan4.isoweekday() - 1)
end_monday = last_monday + timedelta(weeks=iso_today[1] - 1)
current = first_monday
while current <= end_monday:
weeks.append(iso_week_key(current))
current += timedelta(weeks=1)
weeks.reverse()
return weeks
if plan_type == "monthly":
months = []
for y in range(start_year, today.year + 1):
for m in range(1, 13):
if y == today.year and m > today.month:
break
months.append("%04d-%02d" % (y, m))
months.reverse()
return months
if plan_type == "yearly":
years = []
for y in range(start_year, today.year + 1):
years.append(str(y))
years.reverse()
return years
return []
def get_period_key(plan_type): def get_period_key(plan_type):
today = date.today() today = date.today()
if plan_type == "weekly": if plan_type == "weekly":
@@ -132,93 +213,95 @@ class PlanCheckinRequest(BaseModel):
attachments: List[str] = [] attachments: List[str] = []
@router.get("/periods")
def periods(type: str, user=Depends(get_current_user)):
"""
返回指定计划类型下的全部自然周期列表(从 2022 年到当前,倒序)。
用于前端做周期切换选择器。
"""
if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
period_list = generate_period_list(type)
current = get_period_key(type)
return {
"type": type,
"periods": period_list,
"current_period": current,
}
@router.get("/status") @router.get("/status")
def status(type: str, period_key: str = "", user=Depends(get_current_user)): def status(type: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES: if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型") raise HTTPException(status_code=400, detail="无效的计划类型")
period_key = period_key.strip() or get_period_key(type) selected, can_submit, message = _normalize_period(type, period_key)
if type == "weekly":
if parse_iso_week(period_key) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
if period_key != default_weekly_period(date.today()):
raise HTTPException(status_code=400, detail="仅可查询当前周期")
rec = db.fetch_one( rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s", "SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
(user["id"], type, period_key), (user["id"], type, selected),
) )
can_submit, message = (True, "") return {"plan_type": type, "period_key": selected, "checked": rec is not None,
if type == "weekly":
can_submit, message = weekly_submit_state(period_key)
return {"plan_type": type, "period_key": period_key, "checked": rec is not None,
"can_submit": can_submit, "message": message, "can_submit": can_submit, "message": message,
"record": fmt_rec(rec) if rec else None} "record": fmt_rec(rec) if rec else None}
@router.get("/weekly-groups") def _is_teacher(user):
def weekly_groups(period_key: str = "", user=Depends(get_current_user)): """判断用户是否为老师角色。"""
selected, _can_submit, _message = _validate_current_period("weekly", period_key) return (user.get("role") or "").strip().lower() == "teacher"
stats = _groups_stats("weekly", selected)
groups = [
{
"group_name": g["group_name"],
"checked_count": g["checked_count"],
"unchecked_count": g["unchecked_count"],
"total": g["total"],
}
for g in stats
]
return {"period_key": selected, "groups": groups}
@router.get("/weekly-group") def _validate_month_key(period_key: str) -> bool:
def weekly_group(group_name: str = "", period_key: str = "", user=Depends(get_current_user)): """校验月周期格式 YYYY-MM。"""
default_period = default_weekly_period(date.today()) if not period_key:
selected = period_key.strip() or default_period return False
if selected != default_period: parts = period_key.split("-")
raise HTTPException(status_code=400, detail="仅可查看当前周期") if len(parts) != 2:
group_name = group_name.strip() return False
if not group_name: try:
raise HTTPException(status_code=400, detail="缺少小组名") year = int(parts[0])
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,)) month = int(parts[1])
if not group_row: return 2020 <= year <= 2100 and 1 <= month <= 12
raise HTTPException(status_code=404, detail="小组不存在") except ValueError:
return False
def _validate_year_key(period_key: str) -> bool:
"""校验年周期格式 YYYY。"""
if not period_key or len(period_key) != 4:
return False
try:
year = int(period_key)
return 2020 <= year <= 2100
except ValueError:
return False
def _normalize_period(plan_type, period_key):
"""
规范化周期 key,做基础校验(不限制必须是当前周期)。
用于查看类接口:允许查看任意历史周期。
Returns (selected, can_submit, message); 校验失败抛出 400。
"""
selected = period_key.strip() or get_period_key(plan_type)
if plan_type == "weekly":
if parse_iso_week(selected) is None:
raise HTTPException(status_code=400, detail="无效的周周期")
can_submit, message = weekly_submit_state(selected)
elif plan_type == "monthly":
if not _validate_month_key(selected):
raise HTTPException(status_code=400, detail="无效的月周期")
can_submit, message = monthly_submit_state(selected)
elif plan_type == "yearly":
if not _validate_year_key(selected):
raise HTTPException(status_code=400, detail="无效的年周期")
can_submit, message = yearly_submit_state(selected)
else:
can_submit, message = (True, "")
return selected, can_submit, message
can_submit, message = weekly_submit_state(default_period)
members = db.fetch_all(
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
(group_name,),
)
uids = [m["user_id"] for m in members]
rec_map = {}
if uids:
placeholders = ",".join(["%s"] * len(uids))
records = db.fetch_all(
"SELECT user_id, status, checked_at FROM plan_checkins "
"WHERE plan_type='weekly' AND period_key=%s AND user_id IN (" + placeholders + ")",
tuple([selected] + uids),
)
rec_map = {r["user_id"]: r for r in records}
result = []
for m in members:
rec = rec_map.get(m["user_id"])
result.append({
"user_id": m["user_id"],
"name": m["name"],
"filled": rec is not None,
"record": {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
} if rec else None,
})
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
return {
"period_key": selected,
"group_name": group_name,
"can_submit": can_submit,
"message": message,
"members": result,
}
def _validate_current_period(plan_type, period_key): def _validate_current_period(plan_type, period_key):
@@ -240,11 +323,23 @@ def _validate_current_period(plan_type, period_key):
return selected, can_submit, message return selected, can_submit, message
def _groups_stats(plan_type, selected): def _groups_stats(plan_type, selected, group_filter=None):
"""单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。 """单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。
按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。 按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。
group_filter: 只统计指定小组
""" """
where_clauses = []
params = [plan_type, selected]
if group_filter:
where_clauses.append("u.`group` = %s")
params.append(group_filter)
else:
where_clauses.append("u.`group` IS NOT NULL AND u.`group` <> ''")
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
rows = db.fetch_all( rows = db.fetch_all(
"SELECT u.`group` AS group_name, " "SELECT u.`group` AS group_name, "
"COUNT(u.id) AS total, " "COUNT(u.id) AS total, "
@@ -252,9 +347,9 @@ def _groups_stats(plan_type, selected):
"FROM auth_users u " "FROM auth_users u "
"LEFT JOIN plan_checkins pc ON pc.user_id = u.id " "LEFT JOIN plan_checkins pc ON pc.user_id = u.id "
"AND pc.plan_type = %s AND pc.period_key = %s " "AND pc.plan_type = %s AND pc.period_key = %s "
"WHERE u.`group` IS NOT NULL AND u.`group` <> '' " + where_sql + " "
"GROUP BY u.`group` ORDER BY u.`group`", "GROUP BY u.`group` ORDER BY u.`group`",
(plan_type, selected), tuple(params),
) )
groups_list = [] groups_list = []
for item in rows or []: for item in rows or []:
@@ -273,7 +368,7 @@ def _groups_stats(plan_type, selected):
def groups(type: str, period_key: str = "", user=Depends(get_current_user)): def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES: if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型") raise HTTPException(status_code=400, detail="无效的计划类型")
selected, can_submit, message = _validate_current_period(type, period_key) selected, can_submit, message = _normalize_period(type, period_key)
my_rec = db.fetch_one( my_rec = db.fetch_one(
"SELECT " + SELECT_COLS + " FROM plan_checkins " "SELECT " + SELECT_COLS + " FROM plan_checkins "
@@ -283,17 +378,30 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
my_checked = my_rec is not None my_checked = my_rec is not None
my_status = (my_rec or {}).get("status", "") my_status = (my_rec or {}).get("status", "")
groups_list = _groups_stats(type, selected) is_teacher = _is_teacher(user)
my_group = (user.get("group") or "").strip() my_group = (user.get("group") or "").strip()
if my_group:
mine = [g for g in groups_list if g["group_name"] == my_group]
others = [g for g in groups_list if g["group_name"] != my_group]
groups_list = mine + others
unassigned_row = db.fetch_one( if is_teacher:
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''" # 老师:查看所有小组
) groups_list = _groups_stats(type, selected)
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0 if my_group:
mine = [g for g in groups_list if g["group_name"] == my_group]
others = [g for g in groups_list if g["group_name"] != my_group]
groups_list = mine + others
else:
# 学员:只看自己所在小组
if not my_group:
groups_list = []
else:
groups_list = _groups_stats(type, selected, group_filter=my_group)
# 未分组人数仅老师可见
unassigned_count = 0
if is_teacher:
unassigned_row = db.fetch_one(
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
)
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
return { return {
"type": type, "type": type,
@@ -303,6 +411,8 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
"my_checked": my_checked, "my_checked": my_checked,
"my_status": my_status, "my_status": my_status,
"my_record": fmt_rec(my_rec) if my_rec else None, "my_record": fmt_rec(my_rec) if my_rec else None,
"is_teacher": is_teacher,
"my_group": my_group,
"unassigned_count": unassigned_count, "unassigned_count": unassigned_count,
"groups": groups_list, "groups": groups_list,
} }
@@ -312,7 +422,7 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
def group(type: str, group_name: str, period_key: str = "", user=Depends(get_current_user)): def group(type: str, group_name: str, period_key: str = "", user=Depends(get_current_user)):
if type not in PLAN_TYPES: if type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型") raise HTTPException(status_code=400, detail="无效的计划类型")
selected, can_submit, message = _validate_current_period(type, period_key) selected, can_submit, message = _normalize_period(type, period_key)
group_name = group_name.strip() group_name = group_name.strip()
if not group_name: if not group_name:
raise HTTPException(status_code=400, detail="缺少小组名") raise HTTPException(status_code=400, detail="缺少小组名")

View File

@@ -1,7 +1,6 @@
import os import os
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from .. import config, db from .. import config, db
from ..user import user_payload from ..user import user_payload
@@ -19,7 +18,6 @@ def _profile(user):
"name": payload["name"], "name": payload["name"],
"student_id": payload["student_id"], "student_id": payload["student_id"],
"display_name": payload["display_name"], "display_name": payload["display_name"],
"wechat_nickname": user.get("wechat_nickname", ""),
"phone": user.get("phone_display", "") or "", "phone": user.get("phone_display", "") or "",
"group": payload["group"], "group": payload["group"],
"role": payload["role"], "role": payload["role"],
@@ -29,26 +27,11 @@ def _profile(user):
} }
class ProfileUpdateRequest(BaseModel):
wechat_nickname: str = ""
wechat_avatar: str = ""
@router.get("") @router.get("")
def get_profile(user=Depends(get_current_user)): def get_profile(user=Depends(get_current_user)):
return {"success": True, **_profile(user)} return {"success": True, **_profile(user)}
@router.put("")
def update_profile(req: ProfileUpdateRequest, user=Depends(get_current_user)):
db.execute(
"UPDATE auth_users SET wechat_nickname=%s, wechat_avatar=%s WHERE id=%s",
(req.wechat_nickname, req.wechat_avatar, user["id"]),
)
fresh = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (user["id"],))
return {"success": True, **_profile(fresh or user)}
@router.post("/avatar") @router.post("/avatar")
def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)): def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
data = file.file.read() data = file.file.read()
@@ -80,4 +63,3 @@ def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"])) db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"]))
return {"success": True, "wechat_avatar": url_path} return {"success": True, "wechat_avatar": url_path}

View File

@@ -2,9 +2,6 @@ import logging
import uuid import uuid
import hmac import hmac
import hashlib import hashlib
import time
import jwt as pyjwt
from . import config from . import config
@@ -17,16 +14,6 @@ def encrypt_phone(phone):
).hexdigest() ).hexdigest()
def create_access_token(user_id):
now = int(time.time())
payload = {"sub": str(user_id), "iat": now, "exp": now + config.JWT_TTL_SECONDS}
return pyjwt.encode(payload, config.JWT_SECRET, algorithm=config.JWT_ALGORITHM)
def decode_access_token(token):
return pyjwt.decode(token, config.JWT_SECRET, algorithms=[config.JWT_ALGORITHM])
def create_session_token(): def create_session_token():
"""生成写入 auth_sessions 的会话 token(长度适合 token VARCHAR(64))。""" """生成写入 auth_sessions 的会话 token(长度适合 token VARCHAR(64))。"""
return uuid.uuid4().hex + uuid.uuid4().hex[:16] return uuid.uuid4().hex + uuid.uuid4().hex[:16]

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 明缘进阶场 FastAPI 启动脚本 (CentOS7, 固定路径) # 明缘进阶场 FastAPI 启动脚本 (CentOS7, 固定路径)
cd /home/clock_server || exit 1 cd /home/clock_server/clock_test || exit 1
PID_FILE=/home/clock_server/logs/prd/server.pid PID_FILE=/home/clock_server/clock_test/logs/server.pid
LOG_DIR=/home/clock_server/logs LOG_DIR=/home/clock_server/clock_test/logs
OUT_LOG=/home/clock_server/logs/prd/server.out.log OUT_LOG=/home/clock_server/clock_test/logs/server.out.log
ERR_LOG=/home/clock_server/logs/prd/server.err.log ERR_LOG=/home/clock_server/clock_test/logs/server.err.log
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
@@ -19,16 +19,16 @@ fi
# 清理 Python 字节码缓存,确保加载最新代码 # 清理 Python 字节码缓存,确保加载最新代码
echo "清理 __pycache__..." echo "清理 __pycache__..."
find /home/clock_server/server_prd -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null find /home/clock_server/clock_test -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
find /home/clock_server/server_prd -type f -name "*.pyc" -delete 2>/dev/null find /home/clock_server/clock_test -type f -name "*.pyc" -delete 2>/dev/null
echo "清理完成" echo "清理完成"
nohup /home/clock_server/pyenv39/bin/python -m uvicorn server_prd.main:app --host 0.0.0.0 --port 8001 >"$OUT_LOG" 2>"$ERR_LOG" & nohup /home/clock_server/clock_test/pyenv39_test/bin/python -m uvicorn server.main:app --host 0.0.0.0 --port 8000 >"$OUT_LOG" 2>"$ERR_LOG" &
echo $! > "$PID_FILE" echo $! > "$PID_FILE"
sleep 1 sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8001)" echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8000)"
echo "日志: $OUT_LOG" echo "日志: $OUT_LOG"
echo "错误: $ERR_LOG" echo "错误: $ERR_LOG"
else else

37
start_prd.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# 明缘进阶场 FastAPI 启动脚本 (CentOS7, 固定路径)
cd /home/clock_server || exit 1
PID_FILE=/home/clock_server/logs/prd/server.pid
LOG_DIR=/home/clock_server/logs
OUT_LOG=/home/clock_server/logs/prd/server.out.log
ERR_LOG=/home/clock_server/logs/prd/server.err.log
mkdir -p "$LOG_DIR"
if [ -f "$PID_FILE" ]; then
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
echo "服务已在运行 (PID $OLD_PID)"
exit 1
fi
fi
# 清理 Python 字节码缓存,确保加载最新代码
echo "清理 __pycache__..."
find /home/clock_server/server_prd -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
find /home/clock_server/server_prd -type f -name "*.pyc" -delete 2>/dev/null
echo "清理完成"
nohup /home/clock_server/pyenv39/bin/python -m uvicorn server_prd.main:app --host 0.0.0.0 --port 8001 >"$OUT_LOG" 2>"$ERR_LOG" &
echo $! > "$PID_FILE"
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "启动成功 PID $(cat "$PID_FILE") (http://0.0.0.0:8001)"
echo "日志: $OUT_LOG"
echo "错误: $ERR_LOG"
else
echo "启动失败,请查看: $ERR_LOG" >&2
exit 1
fi

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 明缘进阶场 FastAPI 停止脚本 (CentOS7, 固定路径) # 明缘进阶场 FastAPI 停止脚本 (CentOS7, 固定路径)
PID_FILE=/home/clock_server/logs/prd/server.pid PID_FILE=/home/clock_server/clock_test/logs/server.pid
PORT=8001 PORT=8000
kill_pid() { kill_pid() {
local pid="$1" local pid="$1"
@@ -26,7 +26,7 @@ if [ -f "$PID_FILE" ]; then
fi fi
# 2) 按命令行兜底(uvicorn) # 2) 按命令行兜底(uvicorn)
for p in $(pgrep -f "uvicorn server_prd.main:app" 2>/dev/null || true); do for p in $(pgrep -f "uvicorn server.main:app" 2>/dev/null || true); do
kill_pid "$p" kill_pid "$p"
done done

42
stop_prd.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# 明缘进阶场 FastAPI 停止脚本 (CentOS7, 固定路径)
PID_FILE=/home/clock_server/logs/prd/server.pid
PORT=8001
kill_pid() {
local pid="$1"
[ -z "$pid" ] && return 0
echo "停止 PID $pid"
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 30); do
if ! kill -0 "$pid" 2>/dev/null; then break; fi
sleep 0.5
done
if kill -0 "$pid" 2>/dev/null; then
echo "进程未退出,强制结束 PID $pid"
kill -9 "$pid" 2>/dev/null || true
fi
}
# 1) 按 PID 文件
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE" 2>/dev/null || true)"
kill_pid "$PID"
rm -f "$PID_FILE"
fi
# 2) 按命令行兜底(uvicorn)
for p in $(pgrep -f "uvicorn server_prd.main:app" 2>/dev/null || true); do
kill_pid "$p"
done
# 3) 按端口兜底:杀掉占用 8001 的任何进程
FUSER_BIN="$(command -v fuser || true)"
if [ -n "$FUSER_BIN" ]; then
echo "检查并释放端口 $PORT"
$FUSER_BIN -k "$PORT/tcp" 2>/dev/null || true
$FUSER_BIN -k -n tcp "$PORT" 2>/dev/null || true
fi
sleep 1
echo "完成"