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:
2
.env
2
.env
@@ -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
258
index.html
Normal 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">📋</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">✅</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>
|
||||||
3
main.py
3
main.py
@@ -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)
|
||||||
|
|||||||
BIN
media/avatars/0005/avatar.jpeg
Normal file
BIN
media/avatars/0005/avatar.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
3
migrations/001_add_wechat_openid_column.sql
Normal file
3
migrations/001_add_wechat_openid_column.sql
Normal 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`);
|
||||||
4
migrations/002_plan_perf_index.sql
Normal file
4
migrations/002_plan_perf_index.sql
Normal 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`);
|
||||||
35
migrations/003_checkin_records_user_id.sql
Normal file
35
migrations/003_checkin_records_user_id.sql
Normal 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;
|
||||||
|
-- =========================================================
|
||||||
47
migrations/004_plan_checkins_user_id.sql
Normal file
47
migrations/004_plan_checkins_user_id.sql
Normal 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;
|
||||||
|
-- =========================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
288
routes/plan.py
288
routes/plan.py
@@ -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="缺少小组名")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
13
security.py
13
security.py
@@ -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]
|
||||||
18
start.sh
18
start.sh
@@ -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
37
start_prd.sh
Normal 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
|
||||||
6
stop.sh
6
stop.sh
@@ -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
42
stop_prd.sh
Normal 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 "完成"
|
||||||
Reference in New Issue
Block a user