Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc90e21920 | |||
| 7cbbbff3ea | |||
| 154670742c | |||
| b40e95d6c3 | |||
| 1ffe68eb72 | |||
| 72dd2645ec | |||
| 91b8d7bfd4 | |||
| 4f9027bb0f | |||
| 458e13b807 | |||
| 05d9ef26b1 | |||
| 07cf121aac | |||
| 8b647fc31a | |||
| 392c90ec11 | |||
| e38fd89425 | |||
| 9bf2d46f84 | |||
| 152878cc7c | |||
| b90a1dcd8f | |||
| 6bc7ab359c | |||
| bb4a1d2325 | |||
| 96de022871 | |||
| 7c2b9aa3a7 | |||
| ab4f29a474 | |||
| 355557b6ce | |||
| 568329890b | |||
| f1f79dfb6c | |||
| 00bd63cfdf | |||
| e7c9b06056 | |||
| c8ff086b7b | |||
| 07b0cfaa88 | |||
| 4a064b53cc | |||
| cb3e0f8917 | |||
| 3b46e45eb6 | |||
| ef03130980 | |||
| 17d3b07c84 | |||
| 45212898ae | |||
| f2d89a6d61 | |||
| 18ead961de | |||
| 57903d9b34 | |||
| 486e7ede04 | |||
| 27157a7b39 | |||
| 44de36c47b | |||
| 0ca3528afd | |||
| c47fb0a66e | |||
| 2a989a15ef | |||
| 150cd86a43 | |||
| 43ca4df9eb | |||
| 1724a37bcf | |||
| d56769b265 | |||
| 7f39b39643 | |||
| 4f6095f6ba | |||
| 1fc54efad8 | |||
| 13faba27b6 | |||
| 46a4bd3b80 | |||
| 248e28858b | |||
| 005ce14013 | |||
| d2d4847171 | |||
| 22e84f86a4 | |||
| 7fb15d17c8 | |||
| c8f77ca030 | |||
| 145810759e | |||
| 7110743cca | |||
| ab99efe877 | |||
| 3028dbfc86 | |||
| de700ae6c8 | |||
| 47510c7848 | |||
| b4357f746f | |||
| 5042c869b1 | |||
| 96e1c3d1f2 | |||
| ad9b4f4ee3 | |||
| ae4ca4a00e | |||
| e7dac20a02 | |||
| 8fd132fc64 | |||
| f27603f724 | |||
| 5bb39454b8 | |||
| b928c7ceb0 | |||
| 79d531f340 | |||
| 99e01dc338 | |||
| 02dcd6fc32 | |||
| 51f3641a0c | |||
| 9f2f705457 | |||
| 301a4fb971 | |||
| df40b2ae73 | |||
| 317bf4b5a1 | |||
| 1c112fe3dc | |||
| 2f7113ba5e | |||
| 441c40a8cc | |||
| 002c6b8570 | |||
| 2a53ab7041 | |||
| 12db9217d1 | |||
| bd274a0414 | |||
| ad9dd3e638 | |||
| 0a49dc0080 | |||
| 48926ed72f | |||
| c1573f77ac | |||
| d588d10c4c | |||
| 7dac485769 | |||
| 195702c14f | |||
| df550bf2ac | |||
| 75a642f3a2 | |||
| d2ca858816 | |||
| 84f746fa2d | |||
| 7f81d54ea2 | |||
| a802912a55 | |||
| 9074118889 | |||
| 176c43251c | |||
| 839637b3d3 | |||
| 5ee8e47b0f | |||
| af24472e2a | |||
| 518f3506df | |||
| 21b37c6ad8 | |||
| a7180eebba | |||
| 386c6e2bf8 | |||
| 2dd1180bf8 | |||
| a0064881b3 | |||
| 4ca952f2ac | |||
| edeabb0fc0 | |||
| 559b146408 | |||
| d8f2c3676c | |||
| cd963d3834 | |||
| c5e0888639 | |||
| 3bcc32b74e | |||
| 8de7470b9d | |||
| c90784de67 | |||
| 186af6b604 | |||
| a3ec85f4d3 | |||
| ee466b253d | |||
| 2effdbaaf9 | |||
| e979e3cc9f | |||
| 4ec3e55030 | |||
| 0bd354cfee | |||
| 6e145f75e1 | |||
| 879628d0eb | |||
| 8435095b4f | |||
| 200edd75bb | |||
| b39f57b879 | |||
| f20a6b57c1 | |||
| 55eff6e80a | |||
| b436781d7d | |||
| ac743eda88 | |||
| 53cbedeb95 | |||
| 42d3b332a5 | |||
| 021ac7f7bd | |||
| b75e1f9bd9 | |||
| c41176af8d | |||
| 26c5115a32 | |||
| 7639912d7c | |||
| 2f20eebccd | |||
| 3aee45be80 | |||
| 46c8a554ff | |||
| 1507b386bc | |||
| 1a15b359f8 | |||
| f23ab1a261 | |||
| 84a602ae3b | |||
| 35a2f1e432 | |||
| 98689f68f4 | |||
| 3632cb642e | |||
| 47401e7388 | |||
| c5fe95b3ec | |||
| 0f49e7b1da | |||
| 95988c3ca9 | |||
| edd2b248e2 | |||
| 985748a3d3 | |||
| c0ea9ecd8f | |||
| e7e106c606 | |||
| 240e1226ef | |||
| 643e706335 | |||
| 61a8d2b3fb | |||
| 6532025cc0 | |||
| 7a4507903f | |||
| a95c843bf7 | |||
| 764bae23fc | |||
| 1f4d601ffa | |||
| 5de7852786 | |||
| 98631afdc5 | |||
| bc4e739e09 | |||
| 9bffdb7026 | |||
| 42c8b66a01 | |||
| 81b2942cad | |||
| 4b1dea3d3c | |||
| 0d665ad0fe | |||
| 67d925bb91 | |||
| bf0e1656ab | |||
| 66d07781be | |||
| 99c16652a7 | |||
| cc4aa1c00e | |||
| 15eceea38a | |||
| a9dbc2661d | |||
| ebc94ec9a0 | |||
| 98cf8666f6 | |||
| ff3593f057 | |||
| 74422b1848 | |||
| 84f87cd168 | |||
| 386e9269bf | |||
| 17a71b1e2a | |||
| 1b4f029602 | |||
| 2246752724 | |||
| a303782ab4 | |||
| 2cac0cdfee | |||
| 75d334680b | |||
| 35fa5a6560 | |||
| dbd3c88d32 | |||
| 33e29573ca | |||
| 4a80382b60 | |||
| 8eaaf5781a | |||
| 111cc0439c | |||
| a97537660e | |||
| 664d8a2594 | |||
| 6ede88b01b | |||
| 6b42cff492 | |||
| cc1150c1ac | |||
| 032c8e526f | |||
| d442f538e1 | |||
| 20921c6317 | |||
| 097aed6f40 | |||
| a75d57f309 | |||
| fc3bfe09f4 | |||
| b49ef238dd | |||
| 9212273e04 | |||
| 0af640881e | |||
| 7c3e2be828 | |||
| aa0ecfc4f1 | |||
| 0b019ab4b9 | |||
| 5298e30bd0 | |||
| 3a26aaa155 | |||
| 14270ab02d | |||
| edd4e90719 | |||
| 2f93e24a39 | |||
| 49e1cef31c |
@@ -6,6 +6,9 @@ backed/cmd/server/cpdaily-new
|
||||
backed/*.log
|
||||
backed/*.har
|
||||
|
||||
# 本地/生产配置文件(含敏感凭证,禁止入库;模板见 backed/config.toml.example)
|
||||
backed/config.toml
|
||||
|
||||
# Frontend build (rebuilt by Docker multi-stage)
|
||||
backed/dist/
|
||||
frondend/dist/
|
||||
@@ -26,3 +29,6 @@ Thumbs.db
|
||||
|
||||
# Docker
|
||||
*.tar
|
||||
|
||||
# Local DB
|
||||
backed/*.db
|
||||
|
||||
+122
-87
@@ -1,6 +1,6 @@
|
||||
when:
|
||||
- event: tag
|
||||
branch: [v*]
|
||||
tag: v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
@@ -26,129 +26,164 @@ steps:
|
||||
|
||||
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
|
||||
|
||||
# 确保网络存在
|
||||
docker network create likedream-net 2>/dev/null || true
|
||||
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
|
||||
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
|
||||
|
||||
# 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着":
|
||||
# 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚)
|
||||
SLOT_BLUE=8884
|
||||
SLOT_GREEN=8885
|
||||
|
||||
# ============================================================
|
||||
# 第一步:初始化 1Panel 站点配置(首次部署时才需要)
|
||||
# ============================================================
|
||||
# nginx 配置文件在 git clone 后位于 deploy/nginx/ 目录
|
||||
# 同步配置文件到 /opt/cpdaily-xiaozhushou/config.toml
|
||||
mkdir -p /opt/cpdaily-xiaozhushou
|
||||
cp backed/config.toml /opt/cpdaily-xiaozhushou/config.toml
|
||||
|
||||
if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then
|
||||
echo "=== 首次部署:创建 1Panel 站点配置 ==="
|
||||
docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log
|
||||
docker cp deploy/nginx/xiaozhushou.conf 1Panel-openresty-ESmn:/usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf
|
||||
docker cp deploy/nginx/api.conf 1Panel-openresty-ESmn:/www/sites/xiaozhushou/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " 1Panel 站点配置完成 ✓"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 第二步:读取当前代理端口,确定蓝绿状态
|
||||
# 第一步:读取当前反代指向的端口,确定活跃/备用槽
|
||||
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
|
||||
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
|
||||
# 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。
|
||||
# ============================================================
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
|
||||
/www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
|
||||
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前反代端口: $CURRENT_PORT ==="
|
||||
|
||||
if [ "$CURRENT_PORT" = "8884" ]; then
|
||||
PORT_ACTIVE=8884
|
||||
PORT_STANDBY=8885
|
||||
elif [ "$CURRENT_PORT" = "8885" ]; then
|
||||
PORT_ACTIVE=8885
|
||||
PORT_STANDBY=8884
|
||||
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
|
||||
PORT_ACTIVE=$SLOT_BLUE
|
||||
PORT_STANDBY=$SLOT_GREEN
|
||||
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
|
||||
PORT_ACTIVE=$SLOT_GREEN
|
||||
PORT_STANDBY=$SLOT_BLUE
|
||||
else
|
||||
echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885"
|
||||
PORT_ACTIVE=8884
|
||||
PORT_STANDBY=8885
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
|
||||
exit 1
|
||||
fi
|
||||
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||
|
||||
# 清理之前可能残留的临时容器
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
|
||||
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
|
||||
|
||||
# ============================================================
|
||||
# 第三步:在备用端口启动新容器
|
||||
# 第一步附:一次性「收养」旧命名容器
|
||||
# 迁移前的容器名为 cpdaily-xiaozhushou(无端口后缀),本脚本改用
|
||||
# cpdaily-xiaozhushou-<端口>。若不改名,下次部署 rm -f 找不到它 → 端口
|
||||
# 被占用导致 docker run 冲突、部署失败。此处按它实际映射的端口改名(幂等)。
|
||||
# 同时清理历史遗留的 cpdaily-blue 陈尸容器。
|
||||
# ============================================================
|
||||
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
LEGACY_NAME=cpdaily-xiaozhushou
|
||||
if docker inspect "$LEGACY_NAME" >/dev/null 2>&1; then
|
||||
LEGACY_PORT=$(docker port "$LEGACY_NAME" 8093 2>/dev/null | head -1 | grep -oE '[0-9]+$' || true)
|
||||
if [ -n "$LEGACY_PORT" ]; then
|
||||
if docker inspect "cpdaily-xiaozhushou-$LEGACY_PORT" >/dev/null 2>&1; then
|
||||
echo " 发现旧命名容器,但 cpdaily-xiaozhushou-$LEGACY_PORT 已存在,移除旧容器"
|
||||
docker rm -f "$LEGACY_NAME" || true
|
||||
else
|
||||
echo " 收养旧命名容器: $LEGACY_NAME → cpdaily-xiaozhushou-$LEGACY_PORT"
|
||||
docker rename "$LEGACY_NAME" "cpdaily-xiaozhushou-$LEGACY_PORT"
|
||||
fi
|
||||
else
|
||||
echo " 旧命名容器未映射 8093 端口,跳过收养"
|
||||
fi
|
||||
fi
|
||||
docker rm -f cpdaily-blue 2>/dev/null || true
|
||||
|
||||
docker run -d --name cpdaily-xiaozhushou-new \
|
||||
# ============================================================
|
||||
# 第二步:更新「备用槽」容器(此槽当前无流量,停它不会影响线上)
|
||||
# ============================================================
|
||||
echo "=== 停止并移除备用槽旧容器(:$PORT_STANDBY)==="
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
|
||||
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_STANDBY:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第四步:健康检查
|
||||
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
|
||||
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
|
||||
# ============================================================
|
||||
HEALTHY=false
|
||||
for i in $(seq 1 15); do
|
||||
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec cpdaily-xiaozhushou-$PORT_STANDBY \
|
||||
wget -q --timeout=3 -O /dev/null http://127.0.0.1:8093/health 2>/dev/null; then
|
||||
HEALTHY=true
|
||||
break
|
||||
fi
|
||||
echo " 等待新容器就绪... ($i/15)"
|
||||
echo " 等待新容器就绪... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$HEALTHY" = false ]; then
|
||||
echo "ERROR: 新容器未通过健康检查,回滚"
|
||||
docker stop cpdaily-xiaozhushou-new
|
||||
docker rm cpdaily-xiaozhushou-new
|
||||
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo " 新容器已就绪 ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:切换 1Panel 代理到备用端口
|
||||
# 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换)
|
||||
# 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出,
|
||||
# 确保旧连接不再按旧配置转发。
|
||||
# ============================================================
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||
/www/sites/xiaozhushou/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# ============================================================
|
||||
# 第六步:停止旧容器
|
||||
# ============================================================
|
||||
echo "=== 清理旧容器 ==="
|
||||
docker stop cpdaily-xiaozhushou 2>/dev/null || true
|
||||
docker rm cpdaily-xiaozhushou 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 第七步:在活跃端口启动正式容器
|
||||
# ============================================================
|
||||
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
|
||||
docker run -d --name cpdaily-xiaozhushou \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-p $PORT_ACTIVE:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第八步:切回 1Panel 代理到活跃端口
|
||||
# ============================================================
|
||||
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
|
||||
cp $PROXY_CONF $PROXY_CONF.bak
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||
/www/sites/xiaozhushou/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
|
||||
|
||||
# ============================================================
|
||||
# 第九步:清理
|
||||
# ============================================================
|
||||
echo "=== 清理 ==="
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
|
||||
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||
if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then
|
||||
echo "ERROR: nginx 配置校验失败,回滚反代配置"
|
||||
docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发
|
||||
OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \
|
||||
sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ')
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成,等待旧 worker 退出..."
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
STILL=0
|
||||
for p in $OLD_WORKERS; do
|
||||
if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then
|
||||
STILL=$((STILL + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$STILL" = "0" ]; then
|
||||
echo " 旧 worker 已全部退出 ✓"
|
||||
break
|
||||
fi
|
||||
echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)"
|
||||
sleep 2
|
||||
done
|
||||
echo "=== 部署完成 ✓ ==="
|
||||
echo " 反代已指向 :$PORT_STANDBY ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:验证线上确实由新槽服务
|
||||
# ============================================================
|
||||
if ! docker exec 1Panel-openresty-ESmn \
|
||||
sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then
|
||||
echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查"
|
||||
else
|
||||
echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
|
||||
# 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。
|
||||
# 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。
|
||||
# ============================================================
|
||||
echo "=== 当前容器 ==="
|
||||
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
|
||||
|
||||
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
|
||||
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
|
||||
| grep -v '^latest$' | head -n -2 | while read tag; do
|
||||
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -24,6 +24,8 @@ RUN go build -ldflags="-s -w" -o server ./cmd/server/
|
||||
# ============================================================
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# 时区固定为北京时间(tzdata 已安装;进程层 main.go 另有 time.Local 兜底)
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=backend-builder /build/server /app/server
|
||||
COPY --from=frontend-builder /build/dist /app/dist
|
||||
WORKDIR /app
|
||||
|
||||
+88
-14
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
@@ -11,8 +12,10 @@ import (
|
||||
"cpdaily-new/internal/handler"
|
||||
"cpdaily-new/internal/middleware"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
)
|
||||
|
||||
@@ -22,8 +25,23 @@ func main() {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 全局时区固定为中国标准时间(必须在 database.Init 之前设置:
|
||||
// DSN 的 loc=Local 跟随 time.Local,影响数据库 datetime 读写序列化)。
|
||||
// 运行镜像若不设 TZ 默认 UTC,会让所有记录/查询/统计/输出偏移 8 小时
|
||||
// (前端展示为"美国时间"),每月 1 日与每日 0-8 点的边界计算也会错位。
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
log.Fatalf("加载时区 Asia/Shanghai 失败: %v", err)
|
||||
}
|
||||
time.Local = loc
|
||||
|
||||
// 连接数据库
|
||||
database.Init()
|
||||
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
|
||||
handler.BackfillInviteCodes()
|
||||
|
||||
// 连接 Redis(验证码存储/限流)
|
||||
service.InitRedis()
|
||||
|
||||
// 启动限流记录定时清理(每5分钟清理一次)
|
||||
ratelimit.Cleanup(5 * time.Minute)
|
||||
@@ -31,11 +49,35 @@ func main() {
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Cpdaily New",
|
||||
// 信任 openresty 反代(frpc 经 PROXY protocol 透传后,openresty 直接来源为 127.0.0.1;
|
||||
// 宿主机 docker NAT 场景来源为 172.22.0.1),从 X-Forwarded-For 取真实客户端 IP
|
||||
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
|
||||
EnableTrustedProxyCheck: true,
|
||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||
})
|
||||
|
||||
// 中间件
|
||||
app.Use(middleware.CORS())
|
||||
|
||||
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
|
||||
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
|
||||
// 随镜像走版本控制、只影响本服务。
|
||||
// 必须排除所有 SSE 端点(均用 SetBodyStreamWriter 流式推送):
|
||||
// 压缩后的流在部分移动端浏览器(实测 iOS 独立 QQ 浏览器)增量读取不稳定,
|
||||
// 且明文流对所有客户端的逐块解析都更可靠。
|
||||
// - /api/task/execute/*:签到/查寝/返校登记/工作日志执行
|
||||
// - /api/leave/apply: 请假申请
|
||||
// - /api/leave/execute:销假
|
||||
app.Use(compress.New(compress.Config{
|
||||
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
p := c.Path()
|
||||
return strings.HasPrefix(p, "/api/task/execute/") ||
|
||||
p == "/api/leave/apply" ||
|
||||
p == "/api/leave/execute"
|
||||
},
|
||||
}))
|
||||
|
||||
// 路由
|
||||
api := app.Group("/api")
|
||||
|
||||
@@ -44,11 +86,17 @@ func main() {
|
||||
auth.Get("/captcha", handler.GetCaptcha)
|
||||
auth.Get("/schools", handler.SearchSchools)
|
||||
auth.Post("/login", handler.Login)
|
||||
// 手机号验证码登录(无需选择学校:tenantId 由登录响应返回)
|
||||
auth.Post("/sms/send", handler.SmsSend)
|
||||
auth.Post("/sms/login", handler.SmsLogin)
|
||||
auth.Post("/login/mfa", handler.MFAVerify)
|
||||
auth.Post("/login/mfa/send", handler.MFASendCode)
|
||||
auth.Post("/refresh", handler.RefreshToken)
|
||||
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
|
||||
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
task := api.Group("/task", middleware.AuthRequired())
|
||||
@@ -58,6 +106,7 @@ func main() {
|
||||
task.Get("/form/:id", handler.GetTaskForm)
|
||||
task.Post("/form/:id", handler.SaveTaskForm)
|
||||
task.Put("/form/:id", handler.SaveTaskForm)
|
||||
task.Get("/form-data/:id", handler.GetTaskSavedForm)
|
||||
task.Put("/update/:id", handler.UpdateTask)
|
||||
task.Delete("/delete/:id", handler.DeleteTask)
|
||||
task.Get("/execute/:id", handler.ExecuteTask)
|
||||
@@ -66,15 +115,31 @@ func main() {
|
||||
task.Post("/upload-policy", handler.GetUploadPolicy)
|
||||
task.Post("/preview-url", handler.GetPreviewUrl)
|
||||
|
||||
// 销假(请假销假)
|
||||
leave := api.Group("/leave", middleware.AuthRequired())
|
||||
leave.Get("/pending-list", handler.GetPendingLeaves)
|
||||
leave.Post("/approvers", handler.GetLeaveApprovers)
|
||||
leave.Post("/execute", handler.ExecuteLeave)
|
||||
leave.Get("/logs", handler.ListLeaveLogs)
|
||||
leave.Delete("/logs/:id", handler.DeleteLeaveLog)
|
||||
// 请假申请(同模块)
|
||||
leave.Get("/types", handler.GetLeaveTypes)
|
||||
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
|
||||
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
|
||||
leave.Post("/apply", handler.ExecuteLeaveApply)
|
||||
leave.Post("/extend-prefill", handler.GetLeaveExtendPrefill)
|
||||
|
||||
// CDK 兑换
|
||||
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||
cdk.Post("/redeem", handler.UserRedeemCDK)
|
||||
cdk.Get("/vip-info", handler.GetUserVipInfo)
|
||||
|
||||
// 设备绑定
|
||||
device := api.Group("/device")
|
||||
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
|
||||
device.Post("/submit", handler.SubmitDeviceCode)
|
||||
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
|
||||
device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice)
|
||||
|
||||
// 管理后台 API
|
||||
admin := api.Group("/admin")
|
||||
@@ -87,43 +152,51 @@ func main() {
|
||||
adminAuth := api.Group("/admin", middleware.AdminAuthRequired())
|
||||
adminAuth.Get("/cdks", handler.ListCDKs)
|
||||
adminAuth.Post("/cdks", handler.GenerateCDK)
|
||||
adminAuth.Get("/all-cdks", handler.ListAllCDKs)
|
||||
adminAuth.Put("/cdks/:id/status", handler.UpdateCDKStatus)
|
||||
// 全量数据仅超管可见(代理只能看自己生成的)
|
||||
adminAuth.Get("/all-cdks", middleware.AdminOnly(), handler.ListAllCDKs)
|
||||
adminAuth.Get("/agents", handler.ListSubAgents)
|
||||
adminAuth.Post("/agents", handler.CreateSubAgent)
|
||||
adminAuth.Put("/agents/:id", handler.UpdateSubAgent)
|
||||
adminAuth.Get("/agents/:id", handler.GetAgentDetail)
|
||||
adminAuth.Put("/agents/:id/password", handler.UpdateAgentPassword)
|
||||
adminAuth.Put("/agents/:id/status", handler.UpdateAgentStatus)
|
||||
adminAuth.Get("/all-agents", handler.ListAllAgents)
|
||||
adminAuth.Get("/agent/list", handler.ListAllAgents)
|
||||
adminAuth.Get("/all-agents", middleware.AdminOnly(), handler.ListAllAgents)
|
||||
adminAuth.Get("/agent/list", middleware.AdminOnly(), handler.ListAllAgents)
|
||||
|
||||
// 管理后台 - 用户管理(仅admin)
|
||||
adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers)
|
||||
adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail)
|
||||
adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus)
|
||||
// 任务执行日志查询(供"当天执行总次数"卡片点击查看)
|
||||
adminAuth.Get("/task-logs", middleware.AdminOnly(), handler.AdminListTaskLogs)
|
||||
|
||||
// 管理后台 - 公告
|
||||
adminAuth.Post("/announcements", handler.CreateAnnouncement)
|
||||
adminAuth.Get("/announcements", handler.ListAnnouncements)
|
||||
adminAuth.Get("/announcements/:id", handler.GetAnnouncement)
|
||||
adminAuth.Put("/announcements/:id", handler.UpdateAnnouncement)
|
||||
adminAuth.Delete("/announcements/:id", handler.DeleteAnnouncement)
|
||||
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
|
||||
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
|
||||
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
|
||||
adminAuth.Get("/announcements/targets", middleware.AdminOnly(), handler.SearchAnnouncementTargets)
|
||||
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
|
||||
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
|
||||
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
|
||||
|
||||
// 管理后台 - 系统设置
|
||||
adminAuth.Get("/settings", handler.GetSettings)
|
||||
adminAuth.Post("/settings", handler.UpdateSetting)
|
||||
adminAuth.Put("/settings", handler.UpdateSetting)
|
||||
// 管理后台 - 系统设置(全站计费/奖励/SMTP 配置,仅超管)
|
||||
adminAuth.Get("/settings", middleware.AdminOnly(), handler.GetSettings)
|
||||
adminAuth.Post("/settings", middleware.AdminOnly(), handler.UpdateSetting)
|
||||
adminAuth.Put("/settings", middleware.AdminOnly(), handler.UpdateSetting)
|
||||
adminAuth.Post("/settings/test-mail", middleware.AdminOnly(), handler.TestMail)
|
||||
|
||||
// 管理后台 - 我的公告(代理/管理员视角,共用普通用户公告处理器,但 target_type=agent)
|
||||
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
|
||||
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
|
||||
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
|
||||
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
|
||||
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
|
||||
|
||||
// 用户公告
|
||||
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
|
||||
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
|
||||
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
|
||||
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
|
||||
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
|
||||
|
||||
// 设备授权页面(纯 HTML)
|
||||
@@ -155,6 +228,7 @@ func main() {
|
||||
// 管理后台 - 待适配学校
|
||||
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
|
||||
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
|
||||
adminAuth.Post("/pending-schools/:id/reject", handler.AdminRejectSchool)
|
||||
|
||||
// 健康检查(用于蓝绿部署)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
[server]
|
||||
port = 8093
|
||||
mode = "release"
|
||||
|
||||
[database]
|
||||
host = "10.10.10.128"
|
||||
port = 3306
|
||||
user = "xiaozhushou"
|
||||
password = "bh7S4JzPVyY0VLJh44i"
|
||||
dbname = "xiaozhushou"
|
||||
|
||||
[jwt]
|
||||
secret = "cpdaily-new-jwt-secret-key-2026"
|
||||
accessExpire = 3600
|
||||
refreshExpire = 864000
|
||||
|
||||
[captcha]
|
||||
width = 150
|
||||
height = 46
|
||||
length = 4
|
||||
|
||||
[cpdaily]
|
||||
appVersion = "9.8.8"
|
||||
systemVersion = "iOS 17"
|
||||
calVersion = "first_v4"
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
|
||||
captchaBaseUrl = "http://10.10.10.128:8890"
|
||||
captchaApiKey = "baca122..."
|
||||
|
||||
[agent]
|
||||
systemCdkPrice = 10.0
|
||||
@@ -0,0 +1,45 @@
|
||||
# 配置文件模板:复制为 config.toml 并按环境填写真实值。
|
||||
# 注意:本文件随仓库分发,禁止填写任何真实凭证(数据库/Redis/JWT/第三方密钥)。
|
||||
# 生产配置存放于服务器 /opt/cpdaily-xiaozhushou/config.toml(容器以挂载方式读取),
|
||||
# 请勿将生产 config.toml 提交进 git。
|
||||
|
||||
[server]
|
||||
port = 8093
|
||||
mode = "release"
|
||||
|
||||
[database]
|
||||
host = "127.0.0.1"
|
||||
port = 3306
|
||||
user = "your_db_user"
|
||||
password = "your_db_password"
|
||||
dbname = "xiaozhushou"
|
||||
|
||||
[jwt]
|
||||
# 生产环境必须使用强随机密钥(openssl rand -hex 32),示例值仅用于本地开发,切勿用于生产。
|
||||
secret = "dev-only-change-me-please"
|
||||
accessExpire = 3600
|
||||
refreshExpire = 864000
|
||||
# MFA 通道登录短期 token 有效期(秒):与第三方 Cookie 对齐,默认 7200(2 小时)
|
||||
mfaAccessExpire = 7200
|
||||
|
||||
[captcha]
|
||||
width = 150
|
||||
height = 46
|
||||
length = 4
|
||||
|
||||
[cpdaily]
|
||||
appVersion = "9.8.8"
|
||||
systemVersion = "iOS 17"
|
||||
calVersion = "first_v4"
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx", "lntu"]
|
||||
captchaBaseUrl = "http://127.0.0.1:8890"
|
||||
captchaApiKey = "your_captcha_api_key"
|
||||
|
||||
[agent]
|
||||
systemCdkPrice = 10.0
|
||||
|
||||
[redis]
|
||||
host = "127.0.0.1"
|
||||
port = 6379
|
||||
password = "your_redis_password"
|
||||
db = 0
|
||||
+7
-5
@@ -6,11 +6,11 @@ require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/go-resty/resty/v2 v2.16.5
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/gofiber/fiber/v2 v2.52.13
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/time v0.6.0
|
||||
golang.org/x/image v0.46.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
@@ -19,6 +19,9 @@ require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -27,11 +30,10 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
)
|
||||
|
||||
+21
-6
@@ -8,6 +8,14 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
@@ -32,10 +40,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
@@ -51,6 +61,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -87,8 +99,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -107,8 +119,9 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
@@ -119,5 +132,7 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package assets 嵌入静态资源(go:embed 不支持 .. 相对路径,统一放本包供各处引用)。
|
||||
package assets
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// SourGummyDigits Sour Gummy ExtraBold 的 0-9 数字子集字体(SIL OFL 1.1 开源授权,
|
||||
// 可免费商用;来源:Google Fonts / 猫啃网),圆润软糖风格,用于图形验证码。
|
||||
// 经 fonttools pyftsubset 从完整字体(58KB)子集化提取——验证码仅需数字,
|
||||
// 嵌入体积从 58KB 缩至约 3KB,cmap 中仅保留 U+0030-0039。
|
||||
//
|
||||
//go:embed fonts/SourGummy-ExtraBold-Digits.ttf
|
||||
var SourGummyDigits []byte
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Sour Gummy Project Authors (https://github.com/eifetx/Sour-Gummy-Fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -12,10 +12,19 @@ type Config struct {
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Jwt JwtConfig `toml:"jwt"`
|
||||
Captcha CaptchaConfig `toml:"captcha"`
|
||||
Redis RedisConfig `toml:"redis"`
|
||||
Cpdaily CpdailyConfig `toml:"cpdaily"`
|
||||
Agent AgentConfig `toml:"agent"`
|
||||
}
|
||||
|
||||
// RedisConfig 验证码等缓存使用的 Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
Password string `toml:"password"`
|
||||
Db int `toml:"db"`
|
||||
}
|
||||
|
||||
// AgentConfig 代理系统配置
|
||||
type AgentConfig struct {
|
||||
SystemCdkPrice float64 `toml:"systemCdkPrice"` // CDK系统底价(元)
|
||||
@@ -38,6 +47,10 @@ type JwtConfig struct {
|
||||
Secret string `toml:"secret"`
|
||||
AccessExpire int `toml:"accessExpire"`
|
||||
RefreshExpire int `toml:"refreshExpire"`
|
||||
// MfaAccessExpire MFA 通道登录的短期 token 有效期(秒)。MFA 学校第三方会话无法
|
||||
// 自动续期(重新登录需人工输入验证码),token 有效期与第三方 Cookie 对齐;独立于
|
||||
// 普通登录的 accessExpire 便于单独调整(默认 7200 = 2 小时,未配置时自动采用)。
|
||||
MfaAccessExpire int `toml:"mfaAccessExpire"`
|
||||
}
|
||||
|
||||
type CaptchaConfig struct {
|
||||
@@ -78,6 +91,10 @@ func Load(path string) error {
|
||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||
Global.Jwt.Secret = v
|
||||
}
|
||||
// MFA 通道短期 token 有效期默认 2 小时(未配置时采用,部署新版本自动生效)
|
||||
if Global.Jwt.MfaAccessExpire <= 0 {
|
||||
Global.Jwt.MfaAccessExpire = 7200
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// 自动迁移
|
||||
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
|
||||
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.CpLeaveLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
|
||||
log.Fatalf("自动迁移失败: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,70 @@ import (
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AnnouncementTargetItem 公告指定对象候选项(供管理端搜索选择指定用户/代理)
|
||||
type AnnouncementTargetItem struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
StudentID string `json:"studentId,omitempty"`
|
||||
SchoolName string `json:"schoolName,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// SearchAnnouncementTargets 搜索公告指定对象:按 targetType 在普通用户或代理中
|
||||
// 按用户名/学号/姓名/学校模糊查询,返回轻量候选列表供前端选择(最多 20 条)。
|
||||
func SearchAnnouncementTargets(c *fiber.Ctx) error {
|
||||
targetType := c.Query("targetType", "user")
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
const limit = 20
|
||||
|
||||
items := make([]AnnouncementTargetItem, 0, limit)
|
||||
|
||||
if targetType == "agent" {
|
||||
q := database.DB.Model(&model.AgentUser{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("username LIKE ?", like)
|
||||
}
|
||||
var agents []model.AgentUser
|
||||
q.Order("created_at DESC").Limit(limit).Find(&agents)
|
||||
for _, a := range agents {
|
||||
items = append(items, AnnouncementTargetItem{
|
||||
ID: a.ID,
|
||||
Username: a.Username,
|
||||
Role: a.Role,
|
||||
})
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
q := database.DB.Model(&model.SysUser{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("username LIKE ? OR real_name LIKE ? OR student_id LIKE ? OR school_name LIKE ?",
|
||||
like, like, like, like)
|
||||
}
|
||||
var users []model.SysUser
|
||||
q.Order("created_at DESC").Limit(limit).Find(&users)
|
||||
for _, u := range users {
|
||||
items = append(items, AnnouncementTargetItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
RealName: u.RealName,
|
||||
StudentID: u.StudentID,
|
||||
SchoolName: u.SchoolName,
|
||||
})
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// CreateAnnouncementRequest 创建公告请求
|
||||
type CreateAnnouncementRequest struct {
|
||||
Title string `json:"title"`
|
||||
@@ -20,6 +78,7 @@ type CreateAnnouncementRequest struct {
|
||||
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
|
||||
}
|
||||
|
||||
// CreateAnnouncement 创建公告
|
||||
@@ -39,6 +98,10 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||
}
|
||||
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
|
||||
if req.IsPinned && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
|
||||
}
|
||||
|
||||
// 如果指定了用户,验证用户存在
|
||||
if req.TargetUserID != nil {
|
||||
@@ -63,6 +126,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
TargetUserID: req.TargetUserID,
|
||||
ForcePopup: req.ForcePopup,
|
||||
LoopPopup: req.LoopPopup,
|
||||
IsPinned: req.IsPinned,
|
||||
CreatedBy: adminID,
|
||||
}
|
||||
|
||||
@@ -83,6 +147,7 @@ type UpdateAnnouncementRequest struct {
|
||||
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
|
||||
}
|
||||
|
||||
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
|
||||
@@ -113,6 +178,10 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
|
||||
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||
}
|
||||
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
|
||||
if req.IsPinned && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
|
||||
}
|
||||
|
||||
// 如果指定了用户,验证用户存在
|
||||
if req.TargetUserID != nil {
|
||||
@@ -136,6 +205,7 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
|
||||
"target_user_id": req.TargetUserID,
|
||||
"force_popup": req.ForcePopup,
|
||||
"loop_popup": req.LoopPopup,
|
||||
"is_pinned": req.IsPinned,
|
||||
}
|
||||
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
|
||||
slog.Error("更新公告失败", "error", err)
|
||||
|
||||
@@ -5,29 +5,46 @@ import (
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// SysUserListItem 用户列表项
|
||||
type SysUserListItem struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
StudentID string `json:"studentId"`
|
||||
RealName string `json:"realName"`
|
||||
MobileCode string `json:"mobileCode"`
|
||||
MobileType string `json:"mobileType"`
|
||||
SignCount int `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastLoginAt string `json:"lastLoginAt"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
StudentID string `json:"studentId"`
|
||||
RealName string `json:"realName"`
|
||||
MobileCode string `json:"mobileCode"`
|
||||
MobileType string `json:"mobileType"`
|
||||
SignCount float64 `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
|
||||
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
|
||||
VipStatus string `json:"vipStatus"` // vip: active / expired / none
|
||||
VipExpireAt string `json:"vipExpireAt"` // VIP 到期时间,未开通为空
|
||||
TodayStatus string `json:"todayStatus"` // today: success / fail / none
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastLoginAt string `json:"lastLoginAt"`
|
||||
LastLoginIP string `json:"lastLoginIp"`
|
||||
}
|
||||
|
||||
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
|
||||
func inviterDisplay(username, realName string) string {
|
||||
if realName != "" {
|
||||
return realName
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
// ListUsers 获取用户列表(仅admin)
|
||||
@@ -87,36 +104,167 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
taskCountMap[tc.UserID] = tc.Count
|
||||
}
|
||||
|
||||
// 累计成功执行次数(cp_task_logs 签到/查寝 + cp_leave_logs 请假/销假,含用户已删除的软删除记录)
|
||||
// 说明:用户在客户端"删除"日志只是前台不可见(软删除),后台统计必须计入,否则执行次数会凭空减少
|
||||
executedMap := make(map[uint]int64)
|
||||
// 今天执行状态(success/fail/none)
|
||||
todayStatusMap := make(map[uint]string)
|
||||
if len(users) > 0 {
|
||||
userIDs := make([]uint, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
|
||||
type LogStat struct {
|
||||
UserID uint
|
||||
Count int64
|
||||
MaxStatus int
|
||||
}
|
||||
// 签到/查寝成功次数(Unscoped 包含软删除)
|
||||
var logStats []LogStat
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).
|
||||
Select("user_id, COUNT(*) as count, MAX(is_success) as max_status").
|
||||
Where("user_id IN ? AND is_success = 1", userIDs).
|
||||
Group("user_id").
|
||||
Scan(&logStats)
|
||||
for _, ls := range logStats {
|
||||
executedMap[ls.UserID] += ls.Count
|
||||
}
|
||||
// 请假/销假成功次数(Unscoped 包含软删除)
|
||||
var leaveStats []LogStat
|
||||
database.DB.Unscoped().Model(&model.CpLeaveLog{}).
|
||||
Select("user_id, COUNT(*) as count, MAX(is_success) as max_status").
|
||||
Where("user_id IN ? AND is_success = 1", userIDs).
|
||||
Group("user_id").
|
||||
Scan(&leaveStats)
|
||||
for _, ls := range leaveStats {
|
||||
executedMap[ls.UserID] += ls.Count
|
||||
}
|
||||
|
||||
// 今天 00:00 起的状态(含软删除):签到/查寝 + 请假/销假两类日志统一判定
|
||||
// 任一成功 → success;否则任有失败 → fail;都无 → none
|
||||
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
|
||||
var todayTaskLogs []model.CpTaskLog
|
||||
database.DB.Unscoped().Select("user_id, is_success").
|
||||
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
|
||||
Find(&todayTaskLogs)
|
||||
var todayLeaveLogs []model.CpLeaveLog
|
||||
database.DB.Unscoped().Select("user_id, is_success").
|
||||
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
|
||||
Find(&todayLeaveLogs)
|
||||
for _, tl := range todayTaskLogs {
|
||||
if tl.IsSuccess {
|
||||
todayStatusMap[tl.UserID] = "success"
|
||||
} else if todayStatusMap[tl.UserID] != "success" {
|
||||
todayStatusMap[tl.UserID] = "fail"
|
||||
}
|
||||
}
|
||||
for _, tl := range todayLeaveLogs {
|
||||
if tl.IsSuccess {
|
||||
todayStatusMap[tl.UserID] = "success"
|
||||
} else if todayStatusMap[tl.UserID] != "success" {
|
||||
todayStatusMap[tl.UserID] = "fail"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请人信息(本页用户的 inviter_id 去重后一次查询)
|
||||
inviterNameMap := make(map[uint]string)
|
||||
if len(users) > 0 {
|
||||
inviterIDs := make([]uint, 0, len(users))
|
||||
seen := make(map[uint]bool)
|
||||
for _, u := range users {
|
||||
if u.InviterID != nil && !seen[*u.InviterID] {
|
||||
seen[*u.InviterID] = true
|
||||
inviterIDs = append(inviterIDs, *u.InviterID)
|
||||
}
|
||||
}
|
||||
if len(inviterIDs) > 0 {
|
||||
var inviters []model.SysUser
|
||||
database.DB.Select("id, username, real_name").Where("id IN ?", inviterIDs).Find(&inviters)
|
||||
for _, iv := range inviters {
|
||||
inviterNameMap[iv.ID] = inviterDisplay(iv.Username, iv.RealName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]SysUserListItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
lastLogin := ""
|
||||
if u.LastLoginAt != nil {
|
||||
lastLogin = u.LastLoginAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
todayStatus := todayStatusMap[u.ID]
|
||||
if todayStatus == "" {
|
||||
todayStatus = "none"
|
||||
}
|
||||
inviterName := ""
|
||||
if u.InviterID != nil {
|
||||
inviterName = inviterNameMap[*u.InviterID]
|
||||
}
|
||||
// VIP 状态:active=有效期内 / expired=已过期 / none=未开通
|
||||
vipStatus := "none"
|
||||
vipExpireAt := ""
|
||||
if u.VipExpireAt != nil {
|
||||
vipExpireAt = u.VipExpireAt.Format("2006-01-02 15:04")
|
||||
if u.VipExpireAt.After(time.Now()) {
|
||||
vipStatus = "active"
|
||||
} else {
|
||||
vipStatus = "expired"
|
||||
}
|
||||
}
|
||||
items = append(items, SysUserListItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
SchoolID: u.SchoolID,
|
||||
SchoolName: u.SchoolName,
|
||||
StudentID: u.StudentID,
|
||||
RealName: u.RealName,
|
||||
MobileCode: u.MobileCode,
|
||||
MobileType: u.MobileType,
|
||||
SignCount: u.SignCount,
|
||||
InviteReward: u.InviteReward,
|
||||
InvitedCount: u.InvitedCount,
|
||||
TaskCount: taskCountMap[u.ID],
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
LastLoginAt: lastLogin,
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
SchoolID: u.SchoolID,
|
||||
SchoolName: u.SchoolName,
|
||||
StudentID: u.StudentID,
|
||||
RealName: u.RealName,
|
||||
MobileCode: u.MobileCode,
|
||||
MobileType: u.MobileType,
|
||||
SignCount: u.SignCount,
|
||||
InviteReward: u.InviteReward,
|
||||
InvitedCount: u.InvitedCount,
|
||||
InviterID: u.InviterID,
|
||||
InviterName: inviterName,
|
||||
TaskCount: taskCountMap[u.ID],
|
||||
ExecutedCount: executedMap[u.ID],
|
||||
VipStatus: vipStatus,
|
||||
VipExpireAt: vipExpireAt,
|
||||
TodayStatus: todayStatus,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
LastLoginAt: lastLogin,
|
||||
LastLoginIP: u.LastLoginIP,
|
||||
})
|
||||
}
|
||||
|
||||
// 当天统计:执行总次数 / 成功执行总次数 / 新增用户数 / 登录用户数
|
||||
// 执行类统计 = 签到/查寝 + 请假/销假(均含用户已删除的软删除记录)
|
||||
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
|
||||
var todayExecuteCount, todaySuccessCount int64
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).Where("created_at >= ?", todayStart).Count(&todayExecuteCount)
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todaySuccessCount)
|
||||
var todayLeaveExecute, todayLeaveSuccess int64
|
||||
database.DB.Unscoped().Model(&model.CpLeaveLog{}).Where("created_at >= ?", todayStart).Count(&todayLeaveExecute)
|
||||
database.DB.Unscoped().Model(&model.CpLeaveLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todayLeaveSuccess)
|
||||
todayExecuteCount += todayLeaveExecute
|
||||
todaySuccessCount += todayLeaveSuccess
|
||||
var todayNewUsers, todayLoginUsers int64
|
||||
database.DB.Model(&model.SysUser{}).Where("created_at >= ?", todayStart).Count(&todayNewUsers)
|
||||
database.DB.Model(&model.SysUser{}).Where("last_login_at >= ?", todayStart).Count(&todayLoginUsers)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"total": total,
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"items": items,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"todayStats": fiber.Map{
|
||||
"executeCount": todayExecuteCount,
|
||||
"successCount": todaySuccessCount,
|
||||
"newUserCount": todayNewUsers,
|
||||
"loginUserCount": todayLoginUsers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,11 +281,14 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
var taskCount int64
|
||||
database.DB.Model(&model.CpTask{}).Where("user_id = ?", user.ID).Count(&taskCount)
|
||||
|
||||
// 统计被邀请人(通过邀请码查找)
|
||||
var invitees []model.SysUser
|
||||
database.DB.Where("invite_code = ?", user.InviteCode).Find(&invitees) // 查找使用该用户邀请码注册的人,不对,应该查找谁是被这个用户邀请的
|
||||
// 实际上我们无法直接知道谁是被这个用户邀请的,因为没有记录"被谁邀请"的字段
|
||||
// 可以通过统计该用户的 invited_count 来知道邀请人数
|
||||
// 邀请人信息(被谁邀请)
|
||||
var inviterName string
|
||||
if user.InviterID != nil {
|
||||
var inviter model.SysUser
|
||||
if err := database.DB.Select("id, username, real_name").First(&inviter, *user.InviterID).Error; err == nil {
|
||||
inviterName = inviterDisplay(inviter.Username, inviter.RealName)
|
||||
}
|
||||
}
|
||||
|
||||
lastLogin := ""
|
||||
if user.LastLoginAt != nil {
|
||||
@@ -154,23 +305,26 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"schoolId": user.SchoolID,
|
||||
"schoolName": user.SchoolName,
|
||||
"studentId": user.StudentID,
|
||||
"realName": user.RealName,
|
||||
"password": password,
|
||||
"signCount": user.SignCount,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"status": user.Status,
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"schoolId": user.SchoolID,
|
||||
"schoolName": user.SchoolName,
|
||||
"studentId": user.StudentID,
|
||||
"realName": user.RealName,
|
||||
"password": password,
|
||||
"signCount": user.SignCount,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"inviterId": user.InviterID,
|
||||
"inviterName": inviterName,
|
||||
"status": user.Status,
|
||||
"disabledReason": user.DisabledReason,
|
||||
"taskCount": taskCount,
|
||||
"mobileCode": user.MobileCode,
|
||||
"mobileType": user.MobileType,
|
||||
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"lastLoginAt": lastLogin,
|
||||
"taskCount": taskCount,
|
||||
"mobileCode": user.MobileCode,
|
||||
"mobileType": user.MobileType,
|
||||
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"lastLoginAt": lastLogin,
|
||||
"lastLoginIp": user.LastLoginIP,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -217,3 +371,165 @@ func UpdateUserStatus(c *fiber.Ctx) error {
|
||||
"status": req.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminTaskLogItem 任务执行日志列表项(后台查看用)
|
||||
type AdminTaskLogItem struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
TaskType string `json:"taskType"`
|
||||
Title string `json:"title"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
IsSuccess bool `json:"isSuccess"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
// Deleted 表示该记录已被用户在客户端删除(软删除),仅后台可见
|
||||
Deleted bool `json:"deleted"`
|
||||
// Source 数据来源:1=签到/查寝(cp_task_logs) 2=请假/销假(cp_leave_logs),两表 id 独立自增,前端 key 需组合去重
|
||||
Source int `json:"source"`
|
||||
}
|
||||
|
||||
// adminLogUnionSQL 后台执行日志明细的数据源:cp_task_logs(签到/查寝)+ cp_leave_logs(请假/销假)UNION。
|
||||
// 原始 SQL 绕过 GORM 软删除过滤,故含用户已删除记录(deleted 标注);source 区分来源(1=任务 2=请假销假)。
|
||||
// 占位符顺序:dayStart, dayEnd, dayStart, dayEnd
|
||||
// 注意:两表由不同时期建表,字符串列 collation 不一致(utf8mb4_unicode_ci vs general_ci),
|
||||
// 直接 UNION 会报 1271 Illegal mix of collations,故显式 COLLATE 统一到 utf8mb4_unicode_ci。
|
||||
const adminLogUnionSQL = `
|
||||
SELECT id, user_id, task_type, title, real_title, is_success, error_message, created_at, deleted, source FROM (
|
||||
SELECT id, user_id, task_type, title, real_title, is_success, error_message, created_at,
|
||||
(deleted_at IS NOT NULL) AS deleted, 1 AS source
|
||||
FROM cp_task_logs
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
UNION ALL
|
||||
SELECT id, user_id,
|
||||
(CASE action WHEN 'apply' THEN 'leave_apply' WHEN 'extend' THEN 'leave_extend' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
|
||||
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
|
||||
error_message COLLATE utf8mb4_unicode_ci, created_at,
|
||||
(deleted_at IS NOT NULL) AS deleted, 2 AS source
|
||||
FROM cp_leave_logs
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
) AS combined`
|
||||
|
||||
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
|
||||
// 数据源 = 签到/查寝(cp_task_logs)+ 请假/销假(cp_leave_logs),均含用户已删除的软删除记录(标注 deleted);
|
||||
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
|
||||
func AdminListTaskLogs(c *fiber.Ctx) error {
|
||||
// 日期(yyyy-MM-dd,默认当天)
|
||||
date := strings.TrimSpace(c.Query("date", ""))
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
return utils.Fail(c, 400, "日期格式错误,应为 yyyy-MM-dd")
|
||||
}
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
// 执行状态筛选:all / success / fail
|
||||
status := c.Query("status", "all")
|
||||
if status != "all" && status != "success" && status != "fail" {
|
||||
status = "all"
|
||||
}
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
|
||||
// 分页
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
type combinedRow struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
TaskType string
|
||||
Title string
|
||||
RealTitle string
|
||||
IsSuccess bool
|
||||
ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
Deleted bool
|
||||
Source int
|
||||
}
|
||||
|
||||
var rows []combinedRow
|
||||
raw := database.DB.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd)
|
||||
if status == "success" {
|
||||
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd)
|
||||
} else if status == "fail" {
|
||||
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 0", dayStart, dayEnd, dayStart, dayEnd)
|
||||
}
|
||||
raw.Scan(&rows)
|
||||
|
||||
// 用户信息一次性查询
|
||||
userIDs := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
if !seen[r.UserID] {
|
||||
seen[r.UserID] = true
|
||||
userIDs = append(userIDs, r.UserID)
|
||||
}
|
||||
}
|
||||
userMap := make(map[uint]model.SysUser, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var us []model.SysUser
|
||||
database.DB.Select("id, username, real_name, school_name").Where("id IN ?", userIDs).Find(&us)
|
||||
for _, u := range us {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
}
|
||||
|
||||
// 关键字过滤(用户名/姓名/标题)在内存中完成(跨表 UNION 后)
|
||||
like := strings.ToLower(keyword)
|
||||
filtered := make([]AdminTaskLogItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
u := userMap[r.UserID]
|
||||
if keyword != "" {
|
||||
hay := strings.ToLower(u.Username + u.RealName + r.Title + r.RealTitle)
|
||||
if !strings.Contains(hay, like) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, AdminTaskLogItem{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
Username: u.Username,
|
||||
RealName: u.RealName,
|
||||
SchoolName: u.SchoolName,
|
||||
TaskType: r.TaskType,
|
||||
Title: r.Title,
|
||||
RealTitle: r.RealTitle,
|
||||
IsSuccess: r.IsSuccess,
|
||||
ErrorMessage: r.ErrorMessage,
|
||||
CreatedAt: r.CreatedAt,
|
||||
Deleted: r.Deleted,
|
||||
Source: r.Source,
|
||||
})
|
||||
}
|
||||
// 按时间倒序
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||
})
|
||||
|
||||
total := int64(len(filtered))
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(filtered) {
|
||||
start = len(filtered)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"items": filtered[start:end],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
+462
-187
@@ -1,13 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/config"
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,16 +24,19 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetCaptcha 获取图片验证码
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
|
||||
ip := utils.ClientIP(c)
|
||||
ok, wait := service.AllowCaptchaToken(ip)
|
||||
if !ok {
|
||||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||||
}
|
||||
result, err := service.GenerateCaptcha()
|
||||
if err != nil {
|
||||
slog.Error("生成验证码失败", "err", err)
|
||||
return utils.FailServer(c, "生成验证码失败")
|
||||
}
|
||||
// 调试:记录验证码
|
||||
if code, ok := service.GetCaptchaDebug(result.CaptchaID); ok {
|
||||
slog.Info("验证码", "captchaId", result.CaptchaID[:8], "code", code)
|
||||
}
|
||||
return utils.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -54,7 +64,6 @@ type LoginRequest struct {
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
@@ -80,7 +89,11 @@ func Login(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 执行 cpdaily 登录
|
||||
cookies, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
|
||||
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("cpdaily登录失败", "error", err)
|
||||
return c.Status(401).JSON(utils.Response{
|
||||
@@ -92,6 +105,107 @@ func Login(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
|
||||
}
|
||||
|
||||
// MFAVerifyRequest 提交多因子认证验证码
|
||||
type MFAVerifyRequest struct {
|
||||
MfaToken string `json:"mfaToken"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// MFAVerify 提交多因子认证验证码,完成登录
|
||||
// 前端在 /auth/login 返回 needMFA=true 后,引导用户输入今日校园收到的验证码并调用本接口
|
||||
func MFAVerify(c *fiber.Ctx) error {
|
||||
var req MFAVerifyRequest
|
||||
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" || req.Code == "" {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
|
||||
// 读取暂存的 MFA 会话(成功前不删除,允许验证码输错重试;TTL 到期自动失效)
|
||||
sess, err := loadMFASession(c.Context(), req.MfaToken)
|
||||
if err != nil {
|
||||
// HTTP 200 + 专用业务码 4283:前端关闭验证码弹窗并引导重新登录
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(sess.SchoolID)
|
||||
if err != nil {
|
||||
slog.Error("MFA验证: 获取学校详情失败", "schoolId", sess.SchoolID, "error", err)
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "获取学校信息失败,请稍后重试", nil)
|
||||
}
|
||||
|
||||
// 完成 MFA 认证并走完 CAS 票据交换
|
||||
cookies, err := cpd.CompleteMFALogin(c.Context(), sess.MFA, code)
|
||||
if err != nil {
|
||||
slog.Warn("MFA验证失败", "username", sess.Username, "error", err)
|
||||
if errors.Is(err, cpd.ErrMFASessionExpired) {
|
||||
// 会话失效:前端关闭验证码弹窗,引导重新登录
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
|
||||
}
|
||||
// 验证码错误或其他失败:弹窗保留,可重新输入(业务码 4282)
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "验证失败: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
// 验证成功,清理暂存会话
|
||||
deleteMFASession(c.Context(), req.MfaToken)
|
||||
|
||||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
|
||||
}
|
||||
|
||||
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
|
||||
type MFASendCodeRequest struct {
|
||||
MfaToken string `json:"mfaToken"`
|
||||
ReAuthType string `json:"reAuthType"` // 用户选择的验证方式(reAuthType 编码:3=短信 5=今日校园 11=邮箱 等)
|
||||
}
|
||||
|
||||
// MFASendCode 按用户选定的验证方式发送验证码
|
||||
// 前端在 /auth/login 返回 4281(含 authMethods 列表)后,用户选择方式点击「发送验证码」时调用本接口:
|
||||
// - 成功(code 0):前端进入验证码输入阶段(data 含 mobile/codeTime)
|
||||
// - 4284:发送失败(弹窗留在选择界面,可重试或更换方式)
|
||||
// - 4283:验证会话已过期(关闭弹窗引导重新登录)
|
||||
func MFASendCode(c *fiber.Ctx) error {
|
||||
var req MFASendCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
|
||||
sess, err := loadMFASession(c.Context(), req.MfaToken)
|
||||
if err != nil {
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
|
||||
}
|
||||
|
||||
result, err := cpd.SendMFADynamicCode(c.Context(), sess.MFA, req.ReAuthType)
|
||||
if err != nil {
|
||||
slog.Warn("MFA发送验证码失败", "username", sess.Username, "reAuthType", req.ReAuthType, "error", err)
|
||||
if errors.Is(err, cpd.ErrMFASessionExpired) {
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
|
||||
}
|
||||
return utils.RespondWithCode(c, utils.CodeMFASendCodeFailed, "发送失败: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
// 用户选择的验证方式已同步到会话(提交 reAuthSubmit.do 需使用同一方式),更新暂存
|
||||
if err := updateMFASession(c.Context(), req.MfaToken, sess); err != nil {
|
||||
slog.Warn("MFA会话更新失败", "error", err)
|
||||
}
|
||||
|
||||
msg := result.Message
|
||||
if result.Mobile != "" && !strings.Contains(msg, result.Mobile) {
|
||||
msg = fmt.Sprintf("%s,验证码已发送至 %s", msg, result.Mobile)
|
||||
}
|
||||
slog.Info("MFA验证码已发送", "username", sess.Username, "reAuthType", req.ReAuthType)
|
||||
return utils.RespondWithCode(c, 0, msg, fiber.Map{
|
||||
"mobile": result.Mobile,
|
||||
"codeTime": result.CodeTime,
|
||||
})
|
||||
}
|
||||
|
||||
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
|
||||
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
|
||||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool) error {
|
||||
// 检查 MOD_ cookie
|
||||
hasMOD := false
|
||||
for _, cookie := range cookies {
|
||||
@@ -114,10 +228,10 @@ func Login(c *fiber.Ctx) error {
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 构建用户名: 学校简称 + 学号
|
||||
userName := buildUsername(school, req.Username)
|
||||
userName := buildUsername(school, username)
|
||||
|
||||
// 查找或创建用户
|
||||
user, err := findOrCreateUser(userName, req.Password, req.SchoolID, school.Name, req.Username, cookieStr, req.InviteCode)
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
@@ -136,30 +250,37 @@ func Login(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 cookie 和最后登录时间
|
||||
// 更新 cookie 和最后登录时间(含登录 IP)
|
||||
now := time.Now()
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
})
|
||||
|
||||
// 保存明文密码(用于 cookie 过期时自动重新登录)
|
||||
if err := saveRawPassword(user.ID, req.Password); err != nil {
|
||||
if err := saveRawPassword(user.ID, password); err != nil {
|
||||
slog.Error("保存密码失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
// 尝试从 cpdaily 获取用户主页信息并更新本地数据
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
|
||||
// 生成 JWT
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
// 生成 JWT:MFA 通道登录使用与第三方 Cookie 对齐的短期 token
|
||||
var tokenPair *service.TokenPair
|
||||
if shortToken {
|
||||
tokenPair, err = service.GenerateShortTokens(user.ID, user.Username, user.SchoolID, int64(config.Global.Jwt.MfaAccessExpire))
|
||||
} else {
|
||||
tokenPair, err = service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
}
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew, // 本次登录是否为新注册账号(前端据此展示首次使用引导)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,31 +336,74 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
return utils.Success(c, user)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v4"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("guide_version", GuideCompleteVersion).Error; err != nil {
|
||||
return utils.FailServer(c, "记录失败,请稍后重试")
|
||||
}
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库。
|
||||
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
|
||||
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
|
||||
}
|
||||
|
||||
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
|
||||
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
|
||||
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
|
||||
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||||
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
|
||||
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
|
||||
return cpd.FetchUserProfile(cm, wecloudUrl)
|
||||
}
|
||||
|
||||
var profile *cpd.CpdailyUserProfile
|
||||
if allowRefresh {
|
||||
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
|
||||
var e error
|
||||
profile, e = fetch(cm)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
p, err := fetch(cookieMap)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
profile = p
|
||||
}
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
updates := syncProfileToUser(profile)
|
||||
updates["cookie"] = cookieStr // 确保 cookie 最新
|
||||
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
|
||||
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
|
||||
database.DB.Model(&user).Updates(updates)
|
||||
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
|
||||
}
|
||||
|
||||
func syncProfileToUser(profile *cpd.CpdailyUserProfile) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"real_name": profile.Name,
|
||||
"gender": profile.Gender,
|
||||
"avatar": profile.Img,
|
||||
"academy": profile.Academy,
|
||||
"major": profile.Major,
|
||||
"class_name": profile.ClassName,
|
||||
"grade": profile.Grade,
|
||||
"mobile_phone": profile.MobilePhone,
|
||||
"real_name": profile.Name,
|
||||
"gender": profile.Gender,
|
||||
"avatar": profile.Img,
|
||||
"academy": profile.Academy,
|
||||
"major": profile.Major,
|
||||
"class_name": profile.ClassName,
|
||||
"grade": profile.Grade,
|
||||
"mobile_phone": profile.MobilePhone,
|
||||
"org_full_name": profile.OrgFullName,
|
||||
}
|
||||
}
|
||||
@@ -279,29 +443,17 @@ func getInviteeRewardAmount() float64 {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// getRegisterRewardAmount 获取直接注册(无邀请码)获得的奖励积分(从系统设置读取,默认 1.0)
|
||||
func getRegisterRewardAmount() float64 {
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", "register_reward").First(&cfg)
|
||||
if result.Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
|
||||
func createSystemAnnouncement(userID uint, userType string, title, content string) {
|
||||
announcement := model.Announcement{
|
||||
Title: title,
|
||||
Content: content,
|
||||
Source: "system",
|
||||
TargetType: userType,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Source: "system",
|
||||
TargetType: userType,
|
||||
TargetUserID: &userID,
|
||||
ForcePopup: true,
|
||||
LoopPopup: false,
|
||||
CreatedBy: 0, // 系统创建
|
||||
ForcePopup: true,
|
||||
LoopPopup: false,
|
||||
CreatedBy: 0, // 系统创建
|
||||
}
|
||||
if err := database.DB.Create(&announcement).Error; err != nil {
|
||||
slog.Error("创建系统公告失败", "error", err, "userId", userID)
|
||||
@@ -327,157 +479,66 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, error) {
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie string) (*model.SysUser, bool, error) {
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, nil
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和邀请码
|
||||
// 兼容手机号登录:同一学校 + 同学工号 视为同一用户
|
||||
if studentID != "" {
|
||||
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
|
||||
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
|
||||
return nil, false, e2
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和自身邀请码。
|
||||
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
|
||||
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
myInviteCode := generateRandomCode(6)
|
||||
|
||||
// 读取可配置的奖励积分
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
registerReward := getRegisterRewardAmount()
|
||||
|
||||
// 记录邀请人ID,在事务外创建通知
|
||||
var inviterID uint
|
||||
hasValidReferral := false
|
||||
hasRegisterReward := false
|
||||
|
||||
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
|
||||
myInviteCode, err := withUniqueInviteCode(func(code string) error {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: code,
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理邀请码奖励
|
||||
if referralCode != "" {
|
||||
var inviter model.SysUser
|
||||
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
|
||||
// 邀请码无效,仅记录日志,不中断注册流程
|
||||
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 自邀请防护
|
||||
if inviter.ID == user.ID {
|
||||
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&inviter).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
|
||||
// 给被邀请人(新用户)发放奖励积分
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
|
||||
slog.Info("邀请奖励已发放",
|
||||
"inviterId", inviter.ID,
|
||||
"newUserId", user.ID,
|
||||
"inviterReward", inviterReward,
|
||||
"inviteeReward", inviteeReward,
|
||||
)
|
||||
|
||||
inviterID = inviter.ID
|
||||
hasValidReferral = true
|
||||
} else if registerReward > 0 {
|
||||
// 未填写邀请码:发放直接注册奖励积分
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", registerReward))
|
||||
slog.Info("直接注册奖励已发放",
|
||||
"newUserId", user.ID,
|
||||
"registerReward", registerReward,
|
||||
)
|
||||
hasRegisterReward = true
|
||||
}
|
||||
|
||||
return nil
|
||||
return database.DB.Create(&user).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
|
||||
if hasValidReferral {
|
||||
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
|
||||
}
|
||||
|
||||
// 直接注册奖励到账通知
|
||||
if hasRegisterReward {
|
||||
createSystemAnnouncement(user.ID, "user",
|
||||
"注册奖励到账",
|
||||
"欢迎注册!您获得 "+strconv.FormatFloat(registerReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||
return &user, nil
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
|
||||
return &user, true, nil
|
||||
}
|
||||
|
||||
// GenerateMyInviteCode 生成/获取当前用户的邀请码
|
||||
func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
|
||||
func ensureInviteCodeOnLogin(user *model.SysUser) {
|
||||
if err := ensureInviteCode(user); err != nil {
|
||||
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
// 已有邀请码直接返回
|
||||
if user.InviteCode != "" {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新邀请码(6 位随机大写字母数字)
|
||||
code := generateRandomCode(6)
|
||||
user.InviteCode = code
|
||||
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败")
|
||||
}
|
||||
|
||||
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": code,
|
||||
"inviteReward": 0,
|
||||
"invitedCount": 0,
|
||||
"canRedeem": false,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||
@@ -487,13 +548,115 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
return utils.Success(c, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// inviteInfoMap 组装邀请信息返回体(含是否已绑定邀请人,供前端控制「填写邀请码」入口显隐)
|
||||
func inviteInfoMap(user *model.SysUser) fiber.Map {
|
||||
return fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
"hasInviter": user.InviterID != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// BindInviteCodeRequest 填写他人邀请码
|
||||
type BindInviteCodeRequest struct {
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
|
||||
// BindInviteCode 填写他人邀请码:绑定邀请关系并给双方发放邀请奖励。
|
||||
// 约束:每人仅可绑定一次(已绑定邀请人则拒绝);需已绑定设备码;不可填自己的邀请码。
|
||||
func BindInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req BindInviteCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
code := strings.TrimSpace(req.InviteCode)
|
||||
if code == "" {
|
||||
return utils.FailParam(c, "请输入邀请码")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
// 仅可绑定一次
|
||||
if user.InviterID != nil {
|
||||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||||
}
|
||||
// 先校验设备码:未绑定设备码不允许绑定邀请关系
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先绑定设备码")
|
||||
}
|
||||
|
||||
// 查找邀请人
|
||||
var inviter model.SysUser
|
||||
if err := database.DB.Where("invite_code = ?", code).First(&inviter).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 400, "邀请码无效")
|
||||
}
|
||||
return utils.FailServer(c, "查询邀请码失败")
|
||||
}
|
||||
if inviter.ID == user.ID {
|
||||
return utils.Fail(c, 400, "不能填写自己的邀请码")
|
||||
}
|
||||
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
|
||||
// 事务:绑定邀请关系 + 双方发放奖励积分
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 再次校验(防止并发重复绑定)
|
||||
var fresh model.SysUser
|
||||
if err := tx.First(&fresh, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if fresh.InviterID != nil {
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ? AND inviter_id IS NULL", user.ID).
|
||||
Update("inviter_id", inviter.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&model.SysUser{}).Where("id = ?", inviter.ID).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&model.SysUser{}).Where("id = ?", user.ID).
|
||||
Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||||
}
|
||||
slog.Error("绑定邀请码失败", "userId", userID, "code", code, "error", err)
|
||||
return utils.FailServer(c, "绑定失败,请稍后重试")
|
||||
}
|
||||
|
||||
createInviteNotifications(inviter.ID, user.ID, inviterReward, inviteeReward)
|
||||
slog.Info("邀请码绑定成功", "userId", userID, "inviterId", inviter.ID,
|
||||
"inviterReward", inviterReward, "inviteeReward", inviteeReward)
|
||||
|
||||
// 返回最新邀请信息(含新积分),供前端刷新
|
||||
var refreshed model.SysUser
|
||||
database.DB.First(&refreshed, userID)
|
||||
return utils.Success(c, inviteInfoMap(&refreshed))
|
||||
}
|
||||
|
||||
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
|
||||
@@ -526,10 +689,122 @@ func RedeemInviteReward(c *fiber.Ctx) error {
|
||||
// generateRandomCode 生成 N 位随机大写字母数字码
|
||||
func generateRandomCode(n int) string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
code := make([]byte, n)
|
||||
for i := range code {
|
||||
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||
time.Sleep(1)
|
||||
code, err := randomFromCharset(chars, n)
|
||||
if err != nil {
|
||||
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
return string(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// ========== 多因子认证(MFA)会话暂存 ==========
|
||||
|
||||
const (
|
||||
mfaSessionKeyFmt = "mfa_session:%s"
|
||||
mfaSessionTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||||
type mfaSession struct {
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
}
|
||||
|
||||
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
|
||||
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
|
||||
if service.Redis == nil {
|
||||
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||||
}
|
||||
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return utils.FailServer(c, "生成验证会话失败")
|
||||
}
|
||||
mfaToken := hex.EncodeToString(tokenBytes)
|
||||
|
||||
// 密码 AES 加密后暂存,验证成功后用于注册/更新账号
|
||||
encPassword, err := encryptRawPassword(password)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成验证会话失败")
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(mfaSession{
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
MFA: mfaCtx,
|
||||
})
|
||||
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
|
||||
slog.Error("MFA会话暂存失败", "error", err)
|
||||
return utils.FailServer(c, "生成验证会话失败")
|
||||
}
|
||||
|
||||
slog.Info("MFA会话已创建", "school", school.Name, "username", username,
|
||||
"defaultType", mfaCtx.ReAuthType, "authMethods", len(mfaCtx.AuthMethods))
|
||||
// HTTP 200 + 专用业务码 4281:前端按 code 弹出「选择验证方式」弹窗(而非登录失败弹窗)
|
||||
return utils.RespondWithCode(c, utils.CodeMFARequired, "该校登录需多因子认证,请选择验证方式获取验证码", fiber.Map{
|
||||
"needMFA": true,
|
||||
"mfaToken": mfaToken,
|
||||
"expiresIn": int(mfaSessionTTL.Seconds()),
|
||||
"authMethods": mfaCtx.AuthMethods,
|
||||
"defaultType": mfaCtx.ReAuthType,
|
||||
})
|
||||
}
|
||||
|
||||
// loadMFASession 读取暂存的 MFA 会话(验证码输错可重试,成功后才删除)
|
||||
func loadMFASession(ctx context.Context, mfaToken string) (*mfaSession, error) {
|
||||
if service.Redis == nil || mfaToken == "" {
|
||||
return nil, fmt.Errorf("mfa session unavailable")
|
||||
}
|
||||
val, err := service.Redis.Get(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken)).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sess mfaSession
|
||||
if err := json.Unmarshal([]byte(val), &sess); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解密密码
|
||||
raw, err := decryptRawPassword(sess.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.Password = raw
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// deleteMFASession 删除暂存的 MFA 会话
|
||||
func deleteMFASession(ctx context.Context, mfaToken string) {
|
||||
if service.Redis == nil || mfaToken == "" {
|
||||
return
|
||||
}
|
||||
service.Redis.Del(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken))
|
||||
}
|
||||
|
||||
// updateMFASession 更新暂存的 MFA 会话(如用户切换验证方式后),TTL 顺延
|
||||
func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) error {
|
||||
if service.Redis == nil || mfaToken == "" || sess == nil {
|
||||
return fmt.Errorf("mfa session unavailable")
|
||||
}
|
||||
encPassword, err := encryptRawPassword(sess.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(mfaSession{
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
MFA: sess.MFA,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.Redis.Set(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
@@ -15,6 +16,26 @@ import (
|
||||
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
|
||||
var ErrPasswordChanged = errors.New("cpdaily_password_error")
|
||||
|
||||
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
|
||||
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
|
||||
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
|
||||
|
||||
// needReLoginSignal 判定错误是否需要引导用户重新登录。
|
||||
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
|
||||
func needReLoginSignal(err error) bool {
|
||||
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
|
||||
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
|
||||
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
|
||||
func wrapNeedReLogin(e error) error {
|
||||
if errors.Is(e, ErrPasswordChanged) {
|
||||
return ErrPasswordChanged
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
|
||||
}
|
||||
|
||||
// encryptRawPassword AES 加密明文密码并 base64 编码
|
||||
func encryptRawPassword(rawPassword string) (string, error) {
|
||||
encrypted := cpd.AesEncrypt([]byte(rawPassword))
|
||||
@@ -46,29 +67,54 @@ func updateRawPassword(userID uint, rawPassword string) error {
|
||||
return saveRawPassword(userID, rawPassword)
|
||||
}
|
||||
|
||||
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
|
||||
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
|
||||
// 两种来源:
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
func isCookieExpired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, cpd.ErrCookieExpired) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "未登录") ||
|
||||
strings.Contains(msg, "登录已过期")
|
||||
}
|
||||
|
||||
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
|
||||
func isCpdailyAuthError(err error) bool {
|
||||
return isCookieExpired(err)
|
||||
}
|
||||
|
||||
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
|
||||
// 密码错误时返回 ErrPasswordChanged
|
||||
func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 重新从数据库加载用户(获取最新 raw_password)
|
||||
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
|
||||
var fresh model.SysUser
|
||||
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||||
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
|
||||
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
|
||||
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
|
||||
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
|
||||
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
} else if err != nil {
|
||||
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
if fresh.RawPassword == "" {
|
||||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||
return errors.New("未保存登录密码,无法自动重新登录")
|
||||
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
@@ -88,19 +134,25 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
|
||||
|
||||
// 执行 cpdaily 登录
|
||||
cookies, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
|
||||
if err != nil {
|
||||
cookies, mfaCtx, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
|
||||
if err != nil && !errors.Is(err, cpd.ErrMFARequired) {
|
||||
errMsg := err.Error()
|
||||
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
|
||||
|
||||
// 检测密码错误
|
||||
if strings.Contains(errMsg, "账号或密码错误") ||
|
||||
strings.Contains(errMsg, "密码错误") ||
|
||||
strings.Contains(errMsg, "密码不匹配") ||
|
||||
strings.Contains(errMsg, "账号密码") {
|
||||
return ErrPasswordChanged
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
|
||||
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
|
||||
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
|
||||
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// 检查 MOD_ cookie 确保登录成功
|
||||
hasMOD := false
|
||||
@@ -122,12 +174,139 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同步用户信息(异步)
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
|
||||
// 更新传入用户的 cookie(引用),上层重试时使用
|
||||
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
|
||||
user.Cookie = cookieStr
|
||||
|
||||
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
|
||||
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
|
||||
|
||||
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
|
||||
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
|
||||
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["sessionToken"] == "" {
|
||||
return "", errors.New("缺少长期凭证 sessionToken")
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if strings.HasPrefix(c.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return "", errors.New("续期未获取到 MOD_ cookie")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 关键:拿到 MOD_* 不等于续期有效。
|
||||
// RefreshCasCookie 走的 /imUserToken 接口只校验 sessionToken 头,
|
||||
// 不校验 CAS 会话是否仍然有效;当 CASTGC/sessionToken 本身已过期时,
|
||||
// 它仍可能返回 errCode=0 并把 jar 里(旧的/无效的)cookie 全数返回。
|
||||
// 于是这里会“续期成功”,而调用方重试立刻再次判定登录态失效。
|
||||
//
|
||||
// 因此用新 cookie 做一次轻量真实校验:能取到当天任务列表才算真的续上了。
|
||||
// 校验失败则按续期失败处理,让上层引导重新登录,而不是白白重试一轮。
|
||||
if verifyErr := verifyCpdailyCookie(cookieStr, user); verifyErr != nil {
|
||||
return "", fmt.Errorf("续期后凭证仍无效: %w", verifyErr)
|
||||
}
|
||||
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
// verifyCpdailyCookie 用指定 cookie 做一次只读校验,确认登录态真实可用。
|
||||
//
|
||||
// 选择「拉当天任务列表」是因为它最轻、几乎所有学号都有数据,
|
||||
// 且不会产生任何写入副作用(不签到、不改动任何服务端状态)。
|
||||
func verifyCpdailyCookie(cookieStr string, user *model.SysUser) error {
|
||||
m := cpd.ParseCookieJSON(cookieStr)
|
||||
if len(m) == 0 {
|
||||
return errors.New("续期结果为空")
|
||||
}
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
if host == "" {
|
||||
return errors.New("未配置学校地址")
|
||||
}
|
||||
// 复用与正式调用相同的过期检测逻辑
|
||||
return cpd.VerifyCookieUsable(m, host)
|
||||
}
|
||||
|
||||
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
|
||||
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
|
||||
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
|
||||
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
|
||||
//
|
||||
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
|
||||
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
|
||||
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
|
||||
fn func(cm map[string]string) error) (map[string]string, error) {
|
||||
|
||||
err := fn(cookieMap)
|
||||
if err == nil || !isCpdailyAuthError(err) {
|
||||
return cookieMap, err
|
||||
}
|
||||
|
||||
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
// 续期失败:保留原始错误语义,让上层引导重新登录
|
||||
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
|
||||
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
|
||||
}
|
||||
|
||||
// 用续期后的 cookie 重试一次
|
||||
newMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if retryErr := fn(newMap); retryErr != nil {
|
||||
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
|
||||
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
|
||||
// 若不在这里升级,原始 ErrCookieExpired 会一路透传,
|
||||
// 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。
|
||||
if isCpdailyAuthError(retryErr) {
|
||||
slog.Warn("续期后重试仍提示登录态失效,判定需重新登录",
|
||||
"userId", user.ID, "host", host, "error", retryErr)
|
||||
return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr)
|
||||
}
|
||||
return newMap, retryErr
|
||||
}
|
||||
return newMap, nil
|
||||
}
|
||||
|
||||
// userFacingError 把内部错误转成可直接展示给用户的文案。
|
||||
//
|
||||
// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`,
|
||||
// Go 的 `%w` 包装会把它拼进错误链外层(如
|
||||
// "获取签到表单失败: cpdaily_cookie_expired"),
|
||||
// 最终原样出现在界面上,用户完全看不懂。
|
||||
//
|
||||
// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成
|
||||
// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。
|
||||
// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。
|
||||
func userFacingError(prefix string, err error) (msg string, needReLogin bool) {
|
||||
if err == nil {
|
||||
return prefix, false
|
||||
}
|
||||
if needReLoginSignal(err) || isCookieExpired(err) {
|
||||
if prefix == "" {
|
||||
return "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
return prefix + "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
return prefix + err.Error(), false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// userFacingError:保证内部错误码 cpdaily_cookie_expired 不会原样展示给用户。
|
||||
//
|
||||
// 线上问题:用户拿到 "获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败:
|
||||
// cpdaily_cookie_expired",既看不懂也没引导重新登录。
|
||||
func TestUserFacingErrorHidesInternalCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prefix string
|
||||
err error
|
||||
wantContains string
|
||||
wantReLogin bool
|
||||
wantNotSecret string
|
||||
}{
|
||||
{
|
||||
name: "原始 ErrCookieExpired",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: cpd.ErrCookieExpired,
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "多层包装后仍能识别",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: fmt.Errorf("获取当日任务列表失败: %w", fmt.Errorf("解析任务列表响应失败: %w", cpd.ErrCookieExpired)),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "ErrNeedReLogin",
|
||||
prefix: "签到失败(晚查寝): ",
|
||||
err: fmt.Errorf("%w: 续期失败", ErrNeedReLogin),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "JSON 形式未登录",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: errors.New("接口返回: 未登录"),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "",
|
||||
},
|
||||
{
|
||||
name: "普通错误原样保留",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: errors.New("网络超时"),
|
||||
wantContains: "网络超时",
|
||||
wantReLogin: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, needLogin := userFacingError(c.prefix, c.err)
|
||||
if needLogin != c.wantReLogin {
|
||||
t.Fatalf("needReLogin=%v, want %v", needLogin, c.wantReLogin)
|
||||
}
|
||||
if !strings.Contains(msg, c.wantContains) {
|
||||
t.Fatalf("文案 %q 应包含 %q", msg, c.wantContains)
|
||||
}
|
||||
if c.wantNotSecret != "" && strings.Contains(msg, c.wantNotSecret) {
|
||||
t.Fatalf("文案 %q 不应包含内部错误码 %q", msg, c.wantNotSecret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// nil 错误不应触发重新登录。
|
||||
func TestUserFacingErrorNil(t *testing.T) {
|
||||
msg, need := userFacingError("前缀: ", nil)
|
||||
if need {
|
||||
t.Fatal("nil 不应要求重新登录")
|
||||
}
|
||||
if msg != "前缀: " {
|
||||
t.Fatalf("应原样返回前缀,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
+314
-35
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -18,8 +19,10 @@ import (
|
||||
|
||||
// GenerateCDKRequest 生成CDK请求
|
||||
type GenerateCDKRequest struct {
|
||||
Count int `json:"count"` // 生成数量,默认1
|
||||
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1
|
||||
Count int `json:"count"` // 生成数量,默认1
|
||||
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数(type=count),默认1
|
||||
Type string `json:"type"` // CDK类型:count=执行次数(默认)/ vip=VIP时长
|
||||
VipDays int `json:"vipDays"` // VIP天数(type=vip),默认30(一个月)
|
||||
}
|
||||
|
||||
// 获取基础扣费(从系统设置或配置)
|
||||
@@ -35,6 +38,41 @@ func getBaseDeduction() float64 {
|
||||
return config.Global.Agent.SystemCdkPrice
|
||||
}
|
||||
|
||||
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
|
||||
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
|
||||
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
|
||||
func getVipCardPrice(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
|
||||
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
|
||||
|
||||
// getCdkBuyUrl 获取「购买 CDK」跳转链接(系统设置 cdk_buy_url,默认空 = 不展示按钮)
|
||||
func getCdkBuyUrl() string {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", "cdk_buy_url").First(&cfg).Error == nil {
|
||||
return strings.TrimSpace(cfg.Value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCdkBuyVisible 获取「购买 CDK」按钮是否展示(系统设置 cdk_buy_visible,默认 false)
|
||||
// 生效条件:开关为 true 且链接非空(链接为空时前端无处可跳,视为关闭)。
|
||||
func getCdkBuyVisible() bool {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", "cdk_buy_visible").First(&cfg).Error == nil {
|
||||
return cfg.Value == "true" && getCdkBuyUrl() != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GenerateCDK 生成CDK
|
||||
func GenerateCDK(c *fiber.Ctx) error {
|
||||
agentID := c.Locals("adminId").(uint)
|
||||
@@ -43,32 +81,70 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
// CDK 类型校验(默认次数)
|
||||
if req.Type == "" {
|
||||
req.Type = "count"
|
||||
}
|
||||
if req.Type != "count" && req.Type != "vip" {
|
||||
return utils.Fail(c, 400, "CDK类型无效,必须为 count 或 vip")
|
||||
}
|
||||
if req.Count <= 0 {
|
||||
req.Count = 1
|
||||
}
|
||||
if req.Count > 100 {
|
||||
return utils.Fail(c, 400, "单次最多生成100个CDK")
|
||||
}
|
||||
if req.MaxRuns <= 0 {
|
||||
req.MaxRuns = 1
|
||||
}
|
||||
if req.MaxRuns > 999 {
|
||||
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||
}
|
||||
|
||||
// 获取代理信息
|
||||
var agent model.AgentUser
|
||||
if err := database.DB.First(&agent, agentID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "账号不存在")
|
||||
}
|
||||
|
||||
if agent.Status == "disabled" {
|
||||
return utils.Fail(c, 403, "账号已被禁用")
|
||||
}
|
||||
|
||||
baseDeduction := getBaseDeduction()
|
||||
cdkPrice := baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||
costTotal := baseDeduction * float64(req.MaxRuns) * agent.Rate * float64(req.Count)
|
||||
|
||||
var maxRuns int
|
||||
var vipDays int
|
||||
var unitPrice float64 // 单张CDK代理扣费
|
||||
var costPerCDK float64
|
||||
|
||||
if req.Type == "vip" {
|
||||
// VIP CDK:仅支持周卡(7天)/ 月卡(30天)两档
|
||||
if req.VipDays <= 0 {
|
||||
req.VipDays = 30
|
||||
}
|
||||
if req.VipDays != 7 && req.VipDays != 30 {
|
||||
return utils.Fail(c, 400, "VIP CDK 仅支持 7 天(周卡)或 30 天(月卡)")
|
||||
}
|
||||
maxRuns = 0
|
||||
vipDays = req.VipDays
|
||||
var vipPrice float64
|
||||
if req.VipDays == 7 {
|
||||
vipPrice = getVipWeekPrice()
|
||||
} else {
|
||||
vipPrice = getVipMonthPrice()
|
||||
}
|
||||
unitPrice = vipPrice * agent.Rate
|
||||
costPerCDK = vipPrice
|
||||
} else {
|
||||
// 次数 CDK
|
||||
if req.MaxRuns <= 0 {
|
||||
req.MaxRuns = 1
|
||||
}
|
||||
if req.MaxRuns > 999 {
|
||||
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||
}
|
||||
maxRuns = req.MaxRuns
|
||||
vipDays = 0
|
||||
unitPrice = baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||
costPerCDK = baseDeduction * float64(req.MaxRuns)
|
||||
}
|
||||
|
||||
cdkPrice := unitPrice
|
||||
costTotal := unitPrice * float64(req.Count)
|
||||
|
||||
// 事务:扣余额 + 生成CDK
|
||||
cdkCodes := make([]model.CDKCode, 0, req.Count)
|
||||
@@ -89,9 +165,11 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
cdk := model.CDKCode{
|
||||
Code: code,
|
||||
Price: cdkPrice,
|
||||
CostPrice: baseDeduction * float64(req.MaxRuns),
|
||||
CostPrice: costPerCDK,
|
||||
AgentID: agentID,
|
||||
MaxRuns: req.MaxRuns,
|
||||
MaxRuns: maxRuns,
|
||||
Type: req.Type,
|
||||
VipDays: vipDays,
|
||||
Status: "unused",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -123,22 +201,154 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListCDKs 获取自己生成的CDK列表
|
||||
// ListCDKs 获取自己生成的CDK列表(分页)
|
||||
func ListCDKs(c *fiber.Ctx) error {
|
||||
agentID := c.Locals("adminId").(uint)
|
||||
|
||||
var cdks []model.CDKCode
|
||||
database.DB.Where("agent_id = ?", agentID).Order("created_at DESC").Limit(100).Find(&cdks)
|
||||
|
||||
return utils.Success(c, cdks)
|
||||
return listCDKs(c, "agent", agentID)
|
||||
}
|
||||
|
||||
// ListAllCDKs 获取所有CDK列表(仅admin)
|
||||
func ListAllCDKs(c *fiber.Ctx) error {
|
||||
var cdks []model.CDKCode
|
||||
database.DB.Preload("Agent").Order("created_at DESC").Limit(200).Find(&cdks)
|
||||
return listCDKs(c, "all", 0)
|
||||
}
|
||||
|
||||
return utils.Success(c, cdks)
|
||||
// CDKSummary CDK 状态汇总
|
||||
type CDKSummary struct {
|
||||
Total int64 `json:"total"`
|
||||
Unused int64 `json:"unused"`
|
||||
Used int64 `json:"used"`
|
||||
Disabled int64 `json:"disabled"`
|
||||
}
|
||||
|
||||
// listCDKs 分页查询CDK(scope: agent=自己 / all=全部)
|
||||
func listCDKs(c *fiber.Ctx, scope string, agentID uint) error {
|
||||
// 分页
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 筛选:状态 / CDK码关键字
|
||||
status := strings.TrimSpace(c.Query("status", ""))
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
|
||||
base := func() *gorm.DB {
|
||||
q := database.DB.Model(&model.CDKCode{})
|
||||
if scope == "agent" {
|
||||
q = q.Where("agent_id = ?", agentID)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
countQuery := base()
|
||||
if status != "" {
|
||||
countQuery = countQuery.Where("status = ?", status)
|
||||
}
|
||||
if keyword != "" {
|
||||
countQuery = countQuery.Where("code LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
countQuery.Count(&total)
|
||||
|
||||
// 状态汇总(不受筛选影响,按当前可见范围统计)
|
||||
summary := CDKSummary{}
|
||||
for _, s := range []string{"unused", "used", "disabled"} {
|
||||
var n int64
|
||||
base().Where("status = ?", s).Count(&n)
|
||||
switch s {
|
||||
case "unused":
|
||||
summary.Unused = n
|
||||
case "used":
|
||||
summary.Used = n
|
||||
case "disabled":
|
||||
summary.Disabled = n
|
||||
}
|
||||
}
|
||||
summary.Total = summary.Unused + summary.Used + summary.Disabled
|
||||
|
||||
// 数据
|
||||
var cdks []model.CDKCode
|
||||
dataQuery := base()
|
||||
if status != "" {
|
||||
dataQuery = dataQuery.Where("status = ?", status)
|
||||
}
|
||||
if keyword != "" {
|
||||
dataQuery = dataQuery.Where("code LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
dataQuery.Order("created_at DESC").Offset(offset).Limit(pageSize)
|
||||
if scope == "all" {
|
||||
dataQuery = dataQuery.Preload("Agent")
|
||||
}
|
||||
dataQuery.Find(&cdks)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"total": total,
|
||||
"items": cdks,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"summary": summary,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCDKStatusRequest 禁用/恢复CDK请求
|
||||
type UpdateCDKStatusRequest struct {
|
||||
Action string `json:"action"` // disable / enable
|
||||
}
|
||||
|
||||
// UpdateCDKStatus 禁用/恢复CDK(代理仅能操作自己生成的,admin可操作全部)
|
||||
func UpdateCDKStatus(c *fiber.Ctx) error {
|
||||
cdkID := c.Params("id")
|
||||
adminID := c.Locals("adminId").(uint)
|
||||
role, _ := c.Locals("adminRole").(string)
|
||||
|
||||
var req UpdateCDKStatusRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
if req.Action != "disable" && req.Action != "enable" {
|
||||
return utils.Fail(c, 400, "操作无效,必须为 disable 或 enable")
|
||||
}
|
||||
|
||||
var cdk model.CDKCode
|
||||
if err := database.DB.First(&cdk, cdkID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "CDK不存在")
|
||||
}
|
||||
|
||||
// 权限:非 admin 只能操作自己生成的 CDK
|
||||
if role != "admin" && cdk.AgentID != adminID {
|
||||
return utils.Fail(c, 403, "无权操作该CDK")
|
||||
}
|
||||
|
||||
if req.Action == "disable" {
|
||||
if cdk.Status == "disabled" {
|
||||
return utils.Fail(c, 400, "该CDK已是禁用状态")
|
||||
}
|
||||
if cdk.Status != "unused" {
|
||||
return utils.Fail(c, 400, "仅未使用的CDK可以禁用")
|
||||
}
|
||||
if err := database.DB.Model(&cdk).Update("status", "disabled").Error; err != nil {
|
||||
slog.Error("禁用CDK失败", "error", err, "cdkId", cdk.ID)
|
||||
return utils.FailServer(c, "操作失败")
|
||||
}
|
||||
slog.Info("禁用CDK成功", "cdkId", cdk.ID, "code", cdk.Code, "adminId", adminID)
|
||||
return utils.Success(c, fiber.Map{"id": cdk.ID, "status": "disabled"})
|
||||
}
|
||||
|
||||
// enable:仅 disabled 可恢复为 unused
|
||||
if cdk.Status != "disabled" {
|
||||
return utils.Fail(c, 400, "仅已禁用的CDK可以恢复")
|
||||
}
|
||||
if err := database.DB.Model(&cdk).Update("status", "unused").Error; err != nil {
|
||||
slog.Error("恢复CDK失败", "error", err, "cdkId", cdk.ID)
|
||||
return utils.FailServer(c, "操作失败")
|
||||
}
|
||||
slog.Info("恢复CDK成功", "cdkId", cdk.ID, "code", cdk.Code, "adminId", adminID)
|
||||
return utils.Success(c, fiber.Map{"id": cdk.ID, "status": "unused"})
|
||||
}
|
||||
|
||||
// UserRedeemCDKRequest 用户兑换CDK请求
|
||||
@@ -178,6 +388,9 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 使用行锁(悲观锁)确保并发安全
|
||||
var vipExpireAt time.Time
|
||||
redeemType := cdk.Type
|
||||
redeemRuns := cdk.MaxRuns
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 重新查询并锁定该行
|
||||
var lockedCDK model.CDKCode
|
||||
@@ -187,28 +400,54 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
if lockedCDK.Status != "unused" {
|
||||
return errors.New("CDK码已被使用")
|
||||
}
|
||||
if lockedCDK.MaxRuns <= 0 {
|
||||
return errors.New("CDK码已无可运行次数")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// 一次性消耗全部次数
|
||||
totalRuns := lockedCDK.MaxRuns
|
||||
updates := map[string]interface{}{
|
||||
"status": "used",
|
||||
"user_id": userID,
|
||||
"used_at": now,
|
||||
}
|
||||
|
||||
if lockedCDK.Type == "vip" {
|
||||
// ===== VIP CDK:延长 VIP 到期时间 =====
|
||||
if lockedCDK.VipDays <= 0 {
|
||||
return errors.New("VIP CDK配置无效")
|
||||
}
|
||||
// 锁定用户行,防止并发兑换导致到期时间计算错误
|
||||
var lockedUser model.SysUser
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedUser, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 已开通且未过期:在原到期时间上顺延;否则从当前时间起算
|
||||
base := now
|
||||
if lockedUser.VipExpireAt != nil && lockedUser.VipExpireAt.After(base) {
|
||||
base = *lockedUser.VipExpireAt
|
||||
}
|
||||
vipExpireAt = base.AddDate(0, 0, lockedCDK.VipDays)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("vip_expire_at", vipExpireAt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// ===== 次数 CDK:一次性消耗全部次数 =====
|
||||
if lockedCDK.MaxRuns <= 0 {
|
||||
return errors.New("CDK码已无可运行次数")
|
||||
}
|
||||
redeemRuns = lockedCDK.MaxRuns
|
||||
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count + ?", redeemRuns)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count + ?", totalRuns)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -219,15 +458,30 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
if err.Error() == "CDK码已无可运行次数" {
|
||||
return utils.Fail(c, 400, "CDK码已无可运行次数")
|
||||
}
|
||||
if err.Error() == "VIP CDK配置无效" {
|
||||
return utils.Fail(c, 400, "VIP CDK配置无效,请联系管理员")
|
||||
}
|
||||
slog.Error("兑换CDK失败", "error", err)
|
||||
return utils.FailServer(c, "兑换失败")
|
||||
}
|
||||
|
||||
// 按 CDK 类型返回不同提示
|
||||
if redeemType == "vip" {
|
||||
slog.Info("VIP CDK兑换成功", "userId", userID, "code", req.Code, "vipDays", cdk.VipDays, "newExpireAt", vipExpireAt.Format("2006-01-02 15:04:05"))
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "VIP兑换成功",
|
||||
"type": "vip",
|
||||
"vipDays": cdk.VipDays,
|
||||
"vipExpireAt": vipExpireAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("CDK兑换成功", "userId", userID, "code", req.Code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "兑换成功",
|
||||
"totalRuns": cdk.MaxRuns,
|
||||
"type": "count",
|
||||
"totalRuns": redeemRuns,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,3 +500,28 @@ func generateCDKCode() string {
|
||||
func formatFloat(f float64) string {
|
||||
return utils.FormatFloat(f)
|
||||
}
|
||||
|
||||
// GetUserVipInfo 获取当前用户 VIP 信息(到期时间 + 售价,供个人中心展示)
|
||||
func GetUserVipInfo(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.Select("id, vip_expire_at").First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
vipExpireAt := ""
|
||||
if user.VipExpireAt != nil {
|
||||
vipExpireAt = user.VipExpireAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"isVip": isVip,
|
||||
"vipExpireAt": vipExpireAt,
|
||||
"vipWeekPrice": formatFloat(getVipWeekPrice()),
|
||||
"vipMonthPrice": formatFloat(getVipMonthPrice()),
|
||||
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
|
||||
"cdkBuyUrl": getCdkBuyUrl(),
|
||||
"cdkBuyVisible": getCdkBuyVisible(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 扣款额度系统设置 key(每次成功执行对应操作扣减的可签到次数)
|
||||
const (
|
||||
settingDeductCheckin = "deduct_checkin" // 打卡
|
||||
settingDeductLeave = "deduct_leave" // 请假
|
||||
settingDeductReport = "deduct_report" // 销假
|
||||
)
|
||||
|
||||
// parseDeductValue 解析扣款额度字符串:非法或负数时回退到 def。
|
||||
// 允许 0(0 表示不扣费),仅负数与解析失败视为无效。
|
||||
func parseDeductValue(value string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getDeduct 从系统设置读取扣款额度;缺省/非法/负数时回退到 def,允许 0。
|
||||
func getDeduct(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
return parseDeductValue(cfg.Value, def)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getCheckinDeduct 打卡扣减额度(默认 1)
|
||||
func getCheckinDeduct() float64 { return getDeduct(settingDeductCheckin, 1) }
|
||||
|
||||
// getLeaveDeduct 请假扣减额度(默认 1)
|
||||
func getLeaveDeduct() float64 { return getDeduct(settingDeductLeave, 1) }
|
||||
|
||||
// getReportDeduct 销假扣减额度(默认 1)
|
||||
func getReportDeduct() float64 { return getDeduct(settingDeductReport, 1) }
|
||||
|
||||
// fmtDeduct 额度展示:整数去尾零(1.00→1,0.50→0.5,0→0)
|
||||
func fmtDeduct(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParseDeductValue 扣款额度解析:允许 0 与小数,非法/负数回退默认值。
|
||||
func TestParseDeductValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
def float64
|
||||
want float64
|
||||
}{
|
||||
{"空字符串回退默认", "", 1, 1},
|
||||
{"整数", "2", 1, 2},
|
||||
{"小数", "0.5", 1, 0.5},
|
||||
{"零不扣费", "0", 1, 0},
|
||||
{"零带小数", "0.0", 1, 0},
|
||||
{"负数回退默认", "-1", 1, 1},
|
||||
{"非数字回退默认", "abc", 1, 1},
|
||||
{"科学计数", "1e1", 1, 10},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseDeductValue(c.value, c.def); got != c.want {
|
||||
t.Fatalf("parseDeductValue(%q, %v) = %v, want %v", c.value, c.def, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFmtDeduct 额度展示去尾零。
|
||||
func TestFmtDeduct(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{0, "0"},
|
||||
{0.5, "0.5"},
|
||||
{2.25, "2.25"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := fmtDeduct(c.in); got != c.want {
|
||||
t.Fatalf("fmtDeduct(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,32 @@ func SubmitDeviceCode(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "bound"})
|
||||
}
|
||||
|
||||
// RandomBindDevice 随机生成设备码并绑定到当前用户(个人中心「随机」按钮)
|
||||
// 风险:与真实手机绑定的设备码不一致,执行签到时现有手机会提示「频繁切换设备」导致无法签到,
|
||||
// 前端已做连续两次确认;此处不再重复校验,直接生成 UUID(大写,与真实设备码格式一致)绑定。
|
||||
func RandomBindDevice(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return utils.FailServer(c, "生成设备码失败")
|
||||
}
|
||||
// 标准 UUID v4 格式(大写),与真实设备码样式一致
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
deviceCode := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"mobile_code": deviceCode,
|
||||
"mobile_type": "manual",
|
||||
}).Error; err != nil {
|
||||
return utils.FailServer(c, "绑定失败")
|
||||
}
|
||||
|
||||
slog.Info("随机绑定设备码", "userId", userID, "deviceCode", deviceCode)
|
||||
return utils.Success(c, fiber.Map{"deviceCode": deviceCode})
|
||||
}
|
||||
|
||||
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
|
||||
func ClosePage(c *fiber.Ctx) error {
|
||||
c.Type("html")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 6
|
||||
inviteCodeAttempts = 8
|
||||
)
|
||||
|
||||
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
|
||||
func newInviteCode() (string, error) {
|
||||
return randomFromCharset(inviteCodeChars, inviteCodeLength)
|
||||
}
|
||||
|
||||
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
|
||||
func randomFromCharset(charset string, n int) (string, error) {
|
||||
max := big.NewInt(int64(len(charset)))
|
||||
var sb strings.Builder
|
||||
sb.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sb.WriteByte(charset[idx.Int64()])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
|
||||
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
|
||||
// 必须原样返回给调用方。MySQL 报错形如:
|
||||
//
|
||||
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
|
||||
func isInviteCodeConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
|
||||
}
|
||||
|
||||
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
|
||||
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
|
||||
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
|
||||
func withUniqueInviteCode(write func(code string) error) (string, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < inviteCodeAttempts; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成邀请码失败: %w", err)
|
||||
}
|
||||
err = write(code)
|
||||
if err == nil {
|
||||
return code, nil
|
||||
}
|
||||
if !isInviteCodeConflict(err) {
|
||||
return "", err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
|
||||
}
|
||||
|
||||
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
|
||||
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
|
||||
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
|
||||
func ensureInviteCode(user *model.SysUser) error {
|
||||
if strings.TrimSpace(user.InviteCode) != "" {
|
||||
return nil
|
||||
}
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
res := database.DB.Model(&model.SysUser{}).
|
||||
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
|
||||
Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeAlreadySet
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errInviteCodeAlreadySet) {
|
||||
var fresh model.SysUser
|
||||
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
user.InviteCode = fresh.InviteCode
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.InviteCode = code
|
||||
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
var errInviteCodeAlreadySet = errors.New("invite code already set")
|
||||
|
||||
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
|
||||
func BackfillInviteCodes() {
|
||||
var users []model.SysUser
|
||||
if err := database.DB.Select("id", "invite_code").
|
||||
Where("invite_code IS NULL OR invite_code = ''").
|
||||
Find(&users).Error; err != nil {
|
||||
slog.Error("查询缺少邀请码的用户失败", "error", err)
|
||||
return
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return
|
||||
}
|
||||
ok := 0
|
||||
for i := range users {
|
||||
if err := ensureInviteCode(&users[i]); err != nil {
|
||||
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 集成测试:仅在设置 INVITE_IT_DSN 时运行(指向一次性测试库,会建表/清表)。
|
||||
//
|
||||
// INVITE_IT_DSN='root:test@tcp(127.0.0.1:33099)/invtest?charset=utf8mb4&parseTime=True&loc=Local' go test -run Integration ./internal/handler/
|
||||
func setupInviteIT(t *testing.T) {
|
||||
dsn := os.Getenv("INVITE_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 INVITE_IT_DSN,跳过数据库集成测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("清表失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
database.DB = db
|
||||
}
|
||||
|
||||
func insertUser(t *testing.T, name, code string) model.SysUser {
|
||||
u := model.SysUser{Username: name, Password: "x", SchoolID: "s", SchoolName: "S", StudentID: name}
|
||||
if err := database.DB.Create(&u).Error; err != nil {
|
||||
t.Fatalf("插入用户失败: %v", err)
|
||||
}
|
||||
// invite_code 为 varchar NULL + 唯一索引;用原生 SQL 精确构造 NULL / '' 两种缺失形态
|
||||
if code == "<NULL>" {
|
||||
database.DB.Exec("UPDATE sys_user SET invite_code = NULL WHERE id = ?", u.ID)
|
||||
} else {
|
||||
database.DB.Exec("UPDATE sys_user SET invite_code = ? WHERE id = ?", code, u.ID)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestIntegrationConflictErrorText 真实 MySQL 的唯一索引冲突报错能被 isInviteCodeConflict 识别;
|
||||
// 用户名冲突不会被误判
|
||||
func TestIntegrationConflictErrorText(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
insertUser(t, "u1", "AAAAAA")
|
||||
u2 := insertUser(t, "u2", "BBBBBB")
|
||||
err := database.DB.Model(&model.SysUser{}).Where("id = ?", u2.ID).Update("invite_code", "AAAAAA").Error
|
||||
if err == nil {
|
||||
t.Fatal("应触发唯一索引冲突")
|
||||
}
|
||||
t.Logf("邀请码冲突真实报错: %v", err)
|
||||
if !isInviteCodeConflict(err) {
|
||||
t.Fatalf("未识别为邀请码冲突: %v", err)
|
||||
}
|
||||
err = database.DB.Create(&model.SysUser{Username: "u1", Password: "x", InviteCode: "CCCCCC"}).Error
|
||||
if err == nil {
|
||||
t.Fatal("应触发用户名唯一冲突")
|
||||
}
|
||||
t.Logf("用户名冲突真实报错: %v", err)
|
||||
if isInviteCodeConflict(err) {
|
||||
t.Fatalf("用户名冲突被误判为邀请码冲突: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationEnsureAndBackfill 已有码不覆盖;NULL 与空串都会被补齐;补齐后全部唯一
|
||||
func TestIntegrationEnsureAndBackfill(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
keep := insertUser(t, "keep", "KEEP23")
|
||||
insertUser(t, "n1", "<NULL>")
|
||||
insertUser(t, "n2", "<NULL>")
|
||||
insertUser(t, "e1", "") // 空串最多一行(唯一索引),正好覆盖历史数据形态
|
||||
|
||||
BackfillInviteCodes()
|
||||
|
||||
var users []model.SysUser
|
||||
database.DB.Order("id").Find(&users)
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
if u.InviteCode == "" {
|
||||
t.Fatalf("用户 %s 仍无邀请码", u.Username)
|
||||
}
|
||||
if seen[u.InviteCode] {
|
||||
t.Fatalf("邀请码重复: %s", u.InviteCode)
|
||||
}
|
||||
seen[u.InviteCode] = true
|
||||
if u.ID == keep.ID && u.InviteCode != "KEEP23" {
|
||||
t.Fatalf("已有邀请码被覆盖: %s", u.InviteCode)
|
||||
}
|
||||
}
|
||||
// 再跑一次应为幂等(不改动任何人)
|
||||
before := map[uint]string{}
|
||||
for _, u := range users {
|
||||
before[u.ID] = u.InviteCode
|
||||
}
|
||||
BackfillInviteCodes()
|
||||
database.DB.Order("id").Find(&users)
|
||||
for _, u := range users {
|
||||
if before[u.ID] != u.InviteCode {
|
||||
t.Fatalf("第二次补齐改动了用户 %s 的邀请码", u.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationEnsureConcurrent 同一用户并发补发:只写入一次,所有调用方拿到同一个码
|
||||
func TestIntegrationEnsureConcurrent(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
u := insertUser(t, "race", "<NULL>")
|
||||
const n = 20
|
||||
codes := make([]string, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
cp := model.SysUser{ID: u.ID}
|
||||
if err := ensureInviteCode(&cp); err != nil {
|
||||
t.Errorf("ensureInviteCode: %v", err)
|
||||
}
|
||||
codes[i] = cp.InviteCode
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
var fresh model.SysUser
|
||||
database.DB.First(&fresh, u.ID)
|
||||
for i, c := range codes {
|
||||
if c != fresh.InviteCode {
|
||||
t.Fatalf("第 %d 个调用拿到 %q,库里是 %q", i, c, fresh.InviteCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationRegisterAndLoginBackfill 新用户注册自带邀请码;老用户(无码)登录时补发
|
||||
func TestIntegrationRegisterAndLoginBackfill(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
nu, isNew, err := findOrCreateUser("newbie", "pw", "s", "S", "2025001", "")
|
||||
if err != nil || !isNew || nu.InviteCode == "" {
|
||||
t.Fatalf("新用户注册应带邀请码: user=%+v isNew=%v err=%v", nu, isNew, err)
|
||||
}
|
||||
old := insertUser(t, "olduser", "<NULL>")
|
||||
got, isNew, err := findOrCreateUser("olduser", "pw", "s", "S", "olduser", "")
|
||||
if err != nil || isNew {
|
||||
t.Fatalf("老用户登录: isNew=%v err=%v", isNew, err)
|
||||
}
|
||||
if got.ID != old.ID || got.InviteCode == "" {
|
||||
t.Fatalf("老用户登录应补发邀请码: %+v", got)
|
||||
}
|
||||
var fresh model.SysUser
|
||||
database.DB.First(&fresh, old.ID)
|
||||
if fresh.InviteCode != got.InviteCode {
|
||||
t.Fatalf("补发的邀请码未落库: 返回 %q 库里 %q", got.InviteCode, fresh.InviteCode)
|
||||
}
|
||||
// 并发注册多个新用户,邀请码互不重复
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 30)
|
||||
for i := 0; i < 30; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
name := fmt.Sprintf("bulk%02d", i)
|
||||
if _, _, e := findOrCreateUser(name, "pw", "s", "S", name, ""); e != nil {
|
||||
errs <- e
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Fatalf("并发注册失败: %v", e)
|
||||
}
|
||||
var dup int64
|
||||
database.DB.Raw("SELECT COUNT(*) FROM (SELECT invite_code FROM sys_user GROUP BY invite_code HAVING COUNT(*) > 1) t").Scan(&dup)
|
||||
if dup != 0 {
|
||||
t.Fatalf("存在 %d 组重复邀请码", dup)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
|
||||
func TestNewInviteCodeFormat(t *testing.T) {
|
||||
for i := 0; i < 2000; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatalf("newInviteCode error: %v", err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
|
||||
}
|
||||
for _, ch := range code {
|
||||
if !strings.ContainsRune(inviteCodeChars, ch) {
|
||||
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
|
||||
}
|
||||
if strings.ContainsRune("0O1IL", ch) {
|
||||
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
|
||||
func TestNewInviteCodeRandomness(t *testing.T) {
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, _ := newInviteCode()
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
|
||||
if dup := n - len(seen); dup > 2 {
|
||||
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
|
||||
}
|
||||
}
|
||||
|
||||
func dupErr(key string) error {
|
||||
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
|
||||
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
|
||||
calls := 0
|
||||
var tried []string
|
||||
code, err := withUniqueInviteCode(func(c string) error {
|
||||
calls++
|
||||
tried = append(tried, c)
|
||||
if calls < 3 {
|
||||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("应重试成功,得到错误 %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("应调用 3 次,实际 %d", calls)
|
||||
}
|
||||
if code != tried[len(tried)-1] {
|
||||
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
|
||||
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
|
||||
cases := []error{
|
||||
dupErr("sys_user.idx_sys_user_username"),
|
||||
errors.New("connection refused"),
|
||||
}
|
||||
for _, want := range cases {
|
||||
calls := 0
|
||||
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
|
||||
if calls != 1 {
|
||||
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
|
||||
}
|
||||
if !errors.Is(err, want) && err.Error() != want.Error() {
|
||||
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
|
||||
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
|
||||
calls := 0
|
||||
_, err := withUniqueInviteCode(func(string) error {
|
||||
calls++
|
||||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("持续冲突应返回错误")
|
||||
}
|
||||
if calls != inviteCodeAttempts {
|
||||
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
|
||||
func TestGenerateRandomCode(t *testing.T) {
|
||||
a, b := generateRandomCode(32), generateRandomCode(32)
|
||||
if len(a) != 32 || len(b) != 32 {
|
||||
t.Fatalf("长度应为 32:%q %q", a, b)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---------- 销假(请假销假)----------
|
||||
|
||||
// GetPendingLeaves 获取今日校园待销假列表(实时请求今日校园,附带规定销假位置)
|
||||
func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 三易拓新学工5(如山东农业大学):走学校学工系统的请假记录
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorXsgz {
|
||||
sess, sErr := newXsgzSession(c.Context(), &user)
|
||||
if sErr != nil {
|
||||
slog.Error("建立学工系统会话失败", "userId", userID, "error", sErr)
|
||||
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
|
||||
}
|
||||
// 说明:学校的 /api/leave/record/batch-cancel-list 是**辅导员**接口
|
||||
// (学生调用返回「当前用户没有辅导员销假权限」),因此学生端改用
|
||||
// leave_page 拉全部记录,再由 ToLeaveItems 按状态标记 CanReport。
|
||||
recs, _, pErr := sess.XsgzFetchLeavePage(c.Context(), 1, 50)
|
||||
if pErr != nil {
|
||||
slog.Error("获取学工系统请假记录失败", "userId", userID, "error", pErr)
|
||||
return utils.Fail(c, 503, "获取请假记录失败: "+pErr.Error())
|
||||
}
|
||||
return utils.Success(c, sess.ToLeaveItems(recs))
|
||||
}
|
||||
|
||||
// 其他独立学工系统:销假不经辅导猫,给出明确提示
|
||||
if rejectExternalLeaveSystem(c, &user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
|
||||
|
||||
var items []cpd.LeaveItem
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
if isNewFlavor {
|
||||
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
|
||||
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
|
||||
var e error
|
||||
items, e = fetchLeaveNewPendingList(cm, host, &user)
|
||||
return e
|
||||
}
|
||||
}
|
||||
var e error
|
||||
items, e = cpd.FetchLeaveList(cm, host)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// ExecuteLeaveRequest 销假执行参数
|
||||
type ExecuteLeaveRequest struct {
|
||||
LeaveID string `json:"leaveId"` // 今日校园请假记录 ID
|
||||
Longitude string `json:"longitude"` // 销假经度(必填)
|
||||
Latitude string `json:"latitude"` // 销假纬度(必填)
|
||||
Address string `json:"address"` // 销假地址(无规定位置时必填)
|
||||
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
|
||||
// ProjectID 销假所属请假类型(三易拓新学工5 必传 UUID)
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
|
||||
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
|
||||
// 事件流:connecting → fetching_detail → submitting → success / task_error
|
||||
func ExecuteLeave(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req ExecuteLeaveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
req.Longitude = strings.TrimSpace(req.Longitude)
|
||||
req.Latitude = strings.TrimSpace(req.Latitude)
|
||||
req.Address = strings.TrimSpace(req.Address)
|
||||
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
|
||||
if req.LeaveID == "" || req.Longitude == "" || req.Latitude == "" {
|
||||
return utils.FailParam(c, "销假经纬度不能为空")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
|
||||
}
|
||||
// 三易拓新学工5(如山东农业大学):销假提交到学校学工系统
|
||||
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
|
||||
return executeXsgzLeaveCancel(c, &user, &req)
|
||||
}
|
||||
// 其他独立学工系统:销假不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
// SSE 写入器:所有检查在流内进行,通过 SSE event 返回结果
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// 保存销假日志
|
||||
saveLog := func(isSuccess bool, errMsg, title string) {
|
||||
logEntry := model.CpLeaveLog{
|
||||
UserID: userID,
|
||||
LeaveID: req.LeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionReport,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getReportDeduct()
|
||||
|
||||
// 检查可用次数(VIP 免检查;0 额度时恒放行)
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户(销假为同步执行,无任务维度,按用户维度限流)
|
||||
rateKey := fmt.Sprintf("leave:%d", userID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("销假触发限流", "userId", userID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 连接成功
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 新套(leave-new):独立销假链路(详情校验 → 销假提交)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
|
||||
title := "销假"
|
||||
if d, derr := cpd.FetchLeaveNewDetail(cookieMap, host, req.LeaveID); derr == nil {
|
||||
title = leaveLogTitle(&cpd.LeaveDetail{LeaveType: d.TopicName}, req.LeaveID)
|
||||
}
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
|
||||
ok, errMsg, needReLogin := leaveNewExecuteReport(&user, cookieMap, host,
|
||||
req.LeaveID, req.Address, req.Longitude, req.Latitude, req.ImageURL, req.ApproverWid)
|
||||
if !ok {
|
||||
slog.Error("销假失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", errMsg)
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLogin})
|
||||
return
|
||||
}
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "销假成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
|
||||
}
|
||||
slog.Info("销假成功(新套)", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取请假详情
|
||||
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
|
||||
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取请假详情cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假详情失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
saveLog(false, errMsg, leaveLogTitle(detail, ""))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
title := leaveLogTitle(detail, req.LeaveID)
|
||||
|
||||
// 销假地址:用户选择/填写的优先,否则兜底第一个规定销假位置地址;两者皆空时阻止提交
|
||||
if req.Address == "" && len(detail.ReportPlaces) == 0 {
|
||||
errMsg := "该请假未设置规定销假位置,请填写销假地址"
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// 审批人:请假类型要求销假审批(reportApplyFlow=1)时必须选择审批人
|
||||
if detail.ReportApplyFlow && req.ApproverWid == "" {
|
||||
errMsg := "该请假类型销假需要选择审批人,请重新选择"
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 构造表单并提交销假
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
|
||||
form := cpd.BuildLeaveReportForm(detail, req.Address, req.ImageURL, req.ApproverWid)
|
||||
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交销假cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "销假失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "销假成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "销假成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("销假成功", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLeaveApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型)。
|
||||
// 请求:{ leaveId };服务端自行取详情中的 leaveType/起止时间,前端无需重复传递。
|
||||
func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req struct {
|
||||
LeaveID string `json:"leaveId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
if req.LeaveID == "" {
|
||||
return utils.FailParam(c, "leaveId 不能为空")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:销假审批人也不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 新套:销假确认人(getReportApprovers by leaveId)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, approvers)
|
||||
}
|
||||
|
||||
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
|
||||
}
|
||||
if !detail.ReportApplyFlow {
|
||||
return utils.Success(c, []cpd.LeaveApprover{})
|
||||
}
|
||||
|
||||
approvers, err := cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, approvers)
|
||||
}
|
||||
|
||||
// leaveLogTitle 销假日志标题:请假类型名 + "销假";类型名缺失时兜底用 leaveId 前缀避免标题为空
|
||||
func leaveLogTitle(detail *cpd.LeaveDetail, leaveID string) string {
|
||||
if detail != nil && detail.LeaveType != "" {
|
||||
return detail.LeaveType + "销假"
|
||||
}
|
||||
if leaveID != "" {
|
||||
return "销假(" + leaveID + ")"
|
||||
}
|
||||
return "销假"
|
||||
}
|
||||
|
||||
// ListLeaveLogs 获取销假日志列表
|
||||
func ListLeaveLogs(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var logs []model.CpLeaveLog
|
||||
database.DB.Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Find(&logs)
|
||||
|
||||
return utils.Success(c, logs)
|
||||
}
|
||||
|
||||
// DeleteLeaveLog 删除销假日志
|
||||
func DeleteLeaveLog(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var log model.CpLeaveLog
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&log).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 404, "日志不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
database.DB.Delete(&log)
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// executeXsgzLeaveCancel 三易拓新学工5(如山东农业大学)提交销假。
|
||||
//
|
||||
// 契约源自前端 ActionPopup.vue:
|
||||
//
|
||||
// POST /api/leave/student_action/application_submit (multipart)
|
||||
// { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
|
||||
// fields[], cancellationApplyInArea, inArea, address, longitude, latitude }
|
||||
//
|
||||
// 即销假与请假共用同一提交接口,靠 requestType 区分。
|
||||
func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveRequest) error {
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
|
||||
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
|
||||
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "error", "message": "服务内部错误,请稍后重试",
|
||||
})
|
||||
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpLeaveLog{
|
||||
UserID: user.ID,
|
||||
LeaveID: req.LeaveID,
|
||||
Title: "销假申请",
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionReport,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
|
||||
|
||||
sess, err := newXsgzSession(ctx, user)
|
||||
if err != nil {
|
||||
msg := "学工系统登录失败: " + err.Error()
|
||||
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
|
||||
|
||||
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
|
||||
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
|
||||
// 直接导致「缺少请假类型(projectId)」)。
|
||||
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
|
||||
if req.ProjectID == "" {
|
||||
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
|
||||
slog.Warn("xsgz 回查销假详情补 projectId 失败",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
|
||||
} else if d != nil && d.ProjectID != "" {
|
||||
req.ProjectID = d.ProjectID
|
||||
slog.Info("xsgz 已回查补齐 projectId",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
|
||||
}
|
||||
}
|
||||
if req.ProjectID == "" {
|
||||
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
|
||||
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
|
||||
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
|
||||
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
|
||||
if ffErr != nil {
|
||||
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
|
||||
formFields = nil
|
||||
}
|
||||
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段。
|
||||
// 注意:上传类与地区类字段不能被文本值填充(地区类需要
|
||||
// 「选中值,详情」拼接串,写地址会被学校端当成非法地区),
|
||||
// 这两类在下面的 normalizeXsgzFields 里另有处理。
|
||||
for _, f := range formFields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
|
||||
f["value"] = req.Address
|
||||
}
|
||||
}
|
||||
// 按前端 Ie() 语义清洗:上传类去 value、地区类清空、多选数组 join(",")
|
||||
normalizeXsgzFields(formFields)
|
||||
// 提交前预检:必填项无法自动填写时明确指出,避免含糊失败
|
||||
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
|
||||
msg := "该校销假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
|
||||
",请前往学校学工系统办理"
|
||||
slog.Warn("xsgz 销假表单存在未填必填项", "userId", user.ID, "missing", missing)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
|
||||
// v = actionType==="CANCELAPPLY" && cancellationApplyInArea
|
||||
// if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
|
||||
// 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
|
||||
// 但仍把真实计算结果放进 inArea 上报给学校端。
|
||||
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
|
||||
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
|
||||
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
|
||||
} else if len(ranges) > 0 {
|
||||
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
|
||||
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
|
||||
if lngErr != nil || latErr != nil {
|
||||
slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
|
||||
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
|
||||
} else {
|
||||
inArea = cpd.InLocationRange(ranges, lng, lat)
|
||||
}
|
||||
}
|
||||
|
||||
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
|
||||
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
|
||||
if requiresInArea && !inArea {
|
||||
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
|
||||
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
|
||||
ProjectID: req.ProjectID,
|
||||
OriginalID: req.LeaveID,
|
||||
Address: req.Address,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
InArea: inArea,
|
||||
Fields: formFields,
|
||||
})
|
||||
if err != nil {
|
||||
msg := "提交销假失败: " + err.Error()
|
||||
slog.Error("xsgz 提交销假失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
if result.Code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "提交销假失败"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假申请提交成功!"})
|
||||
slog.Info("xsgz 销假申请成功", "userId", user.ID, "leaveId", req.LeaveID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---------- 请假申请 ----------
|
||||
|
||||
// loadLeaveUser 读取当前用户(请假链路公共前置)
|
||||
func loadLeaveUser(c *fiber.Ctx) (*model.SysUser, error) {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return nil, fmt.Errorf("请先登录")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetLeaveTypes 获取请假类型列表(含全部权威开关,前端据此渲染表单)。
|
||||
// 按学校应用的套件分流:新套(leave-new)翻译为同一契约返回。
|
||||
func GetLeaveTypes(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 三易拓新学工5(如山东农业大学):请假类型来自学校学工系统
|
||||
if schoolLeaveFlavor(user, cookieMap) == cpd.LeaveFlavorXsgz {
|
||||
sess, sErr := newXsgzSession(c.Context(), user)
|
||||
if sErr != nil {
|
||||
slog.Error("建立学工系统会话失败", "userId", user.ID, "error", sErr)
|
||||
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
|
||||
}
|
||||
projs, tErr := sess.XsgzFetchLeaveTypes(c.Context())
|
||||
if tErr != nil {
|
||||
slog.Error("获取学工系统请假类型失败", "userId", user.ID, "error", tErr)
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+tErr.Error())
|
||||
}
|
||||
return utils.Success(c, xsgzTypesToLeaveTypes(projs))
|
||||
}
|
||||
|
||||
// 其他独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
var types []cpd.LeaveType
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
|
||||
} else {
|
||||
types, err = cpd.FetchLeaveTypes(cookieMap, host)
|
||||
}
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
|
||||
} else {
|
||||
types, err = cpd.FetchLeaveTypes(cookieMap, host)
|
||||
}
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, types)
|
||||
}
|
||||
|
||||
// GetLeaveApplyApprovers 获取请假可选审批人(needApproval=1 的类型)。
|
||||
// 请求:{ leaveType, startTime, endTime }(与销假审批人接口区分:此处选择类型发生在提交前)。
|
||||
// 新套额外返回 flowData(提交请假时原样回传),前端不透明缓存即可。
|
||||
func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LeaveType int `json:"leaveType"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
if req.StartTime == "" || req.EndTime == "" {
|
||||
return utils.FailParam(c, "开始/结束时间不能为空")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:请假审批人不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
res, err := fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
|
||||
}
|
||||
// 保持与旧套一致的数组契约(flowData 由服务端在执行时重新获取,前端无需感知)
|
||||
return utils.Success(c, res.Approvers)
|
||||
}
|
||||
|
||||
approvers, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, approvers)
|
||||
}
|
||||
|
||||
// ExecuteLeaveApplyRequest 请假申请执行参数
|
||||
type ExecuteLeaveApplyRequest struct {
|
||||
LeaveType int `json:"leaveType"` // 类型编码
|
||||
// XsgzProjectID 三易拓新学工5 的请假类型 ID(UUID)。
|
||||
// 这类系统的类型 ID 不是整数 code,前端从 /leave/types 的 projectId 字段回传。
|
||||
XsgzProjectID string `json:"projectId"`
|
||||
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
|
||||
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
|
||||
LeaveReason string `json:"leaveReason"` // 请假原因
|
||||
OutStatus bool `json:"outStatus"` // 是否离校
|
||||
UrgencyMobile string `json:"urgencyMobile"` // 紧急联系人(离校必填)
|
||||
Address string `json:"address"` // 目的地(离校必填)
|
||||
DetailAddress string `json:"detailAddress"` // 详细地址(离校必填)
|
||||
Longitude string `json:"longitude"` // 定位经度
|
||||
Latitude string `json:"latitude"` // 定位纬度
|
||||
LocationAddr string `json:"locationAddr"` // 定位地址
|
||||
ImageURLs []string `json:"imageUrls"` // 附件图片 URL 列表(可选,最多4张且不超学校上限)
|
||||
ImageURL string `json:"imageUrl"` // 兼容旧前端:单附件 URL(与 imageUrls 合并去重)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid(候选为空时可不传)
|
||||
ApproverId string `json:"approverId"` // 审批人 userId
|
||||
ApproverName string `json:"approverName"` // 审批人姓名
|
||||
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
|
||||
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
|
||||
// —— 续假(仅新套)——
|
||||
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
|
||||
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
|
||||
// 自定义附加字段的作答(新套 selfDefineFields,如「当天是否返校」)
|
||||
SelfDefineAnswers []map[string]interface{} `json:"selfDefineAnswers"`
|
||||
}
|
||||
|
||||
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
|
||||
func mergeApplyImageURLs(urls []string, legacy string) []string {
|
||||
out := make([]string, 0, len(urls)+1)
|
||||
seen := make(map[string]bool, len(urls)+1)
|
||||
for _, u := range append(append([]string{}, urls...), legacy) {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExecuteLeaveApply 执行请假申请(SSE 同步执行:checkOnceLeave → 提交)。
|
||||
// 事件流:connecting → submitting → success / task_error
|
||||
func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req ExecuteLeaveApplyRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.StartTime = strings.TrimSpace(req.StartTime)
|
||||
req.EndTime = strings.TrimSpace(req.EndTime)
|
||||
req.LeaveReason = strings.TrimSpace(req.LeaveReason)
|
||||
req.Longitude = strings.TrimSpace(req.Longitude)
|
||||
req.Latitude = strings.TrimSpace(req.Latitude)
|
||||
req.LocationAddr = strings.TrimSpace(req.LocationAddr)
|
||||
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
|
||||
// 附件:多图字段 + 旧前端兼容单图字段,去重合并
|
||||
imageURLs := mergeApplyImageURLs(req.ImageURLs, req.ImageURL)
|
||||
if len(imageURLs) > cpd.LeaveMaxAttachCount(0) {
|
||||
return utils.FailParam(c, "附件最多 4 张")
|
||||
}
|
||||
|
||||
if req.StartTime == "" || req.EndTime == "" {
|
||||
return utils.FailParam(c, "开始/结束时间不能为空")
|
||||
}
|
||||
if req.LeaveReason == "" {
|
||||
return utils.FailParam(c, "请假原因不能为空")
|
||||
}
|
||||
if req.Longitude == "" || req.Latitude == "" {
|
||||
return utils.FailParam(c, "定位经纬度不能为空")
|
||||
}
|
||||
if req.LocationAddr == "" {
|
||||
return utils.FailParam(c, "定位地址不能为空")
|
||||
}
|
||||
if req.OutStatus {
|
||||
if strings.TrimSpace(req.UrgencyMobile) == "" {
|
||||
return utils.FailParam(c, "紧急联系人手机号不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.Address) == "" {
|
||||
return utils.FailParam(c, "目的地不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.DetailAddress) == "" {
|
||||
return utils.FailParam(c, "详细地址不能为空")
|
||||
}
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
|
||||
}
|
||||
// 三易拓新学工5(如山东农业大学):提交到学校学工系统
|
||||
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
|
||||
return executeXsgzLeaveApply(c, &user, &req, imageURLs)
|
||||
}
|
||||
// 其他独立学工系统:请假提交不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
|
||||
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
|
||||
applyAction := model.LeaveActionApply
|
||||
if req.IsExtend {
|
||||
applyAction = model.LeaveActionExtend
|
||||
}
|
||||
|
||||
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
logEntry := model.CpLeaveLog{
|
||||
UserID: userID,
|
||||
LeaveID: applyLeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: applyAction,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getLeaveDeduct()
|
||||
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:与销假同规则(同一用户维度)
|
||||
rateKey := fmt.Sprintf("leave-apply:%d", userID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("请假触发限流", "userId", userID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 新套(leave-new):独立请假链路(类型详情复核 + 审批流 + 明文 JSON 提交)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
ok, errMsg, needReLogin, needApprover := leaveNewExecuteApply(&user, cookieMap, host, req, imageURLs)
|
||||
if !ok {
|
||||
slog.Error("请假申请失败(新套)", "userId", userID, "error", errMsg)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error",
|
||||
"message": errMsg,
|
||||
"needReLogin": needReLogin,
|
||||
"needApprover": needApprover,
|
||||
})
|
||||
return
|
||||
}
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "请假申请提交成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!(VIP 免扣次数)"})
|
||||
}
|
||||
slog.Info("请假申请成功(新套)", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 获取类型配置(服务端复核开关,避免前端被绕过)
|
||||
cfg, err := cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假类型配置失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
if cfg.DisableTime == 1 {
|
||||
errMsg := "该请假类型当前不可用,请选择其他类型"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if cfg.ReasonWordLimit > 0 && len([]rune(req.LeaveReason)) < cfg.ReasonWordLimit {
|
||||
errMsg := fmt.Sprintf("请假原因至少 %d 字", cfg.ReasonWordLimit)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 审批人:needApproval=1 且学校配置了候选审批人时必选;
|
||||
// 候选列表为空(学校未配置审批节点人员,如 ysdwl「大四学生请假」)时允许不选,跳过即可
|
||||
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
|
||||
candidates, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
errMsg := "获取请假审批人失败: " + err.Error()
|
||||
slog.Warn("校验审批人候选失败", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
errMsg := "该请假类型需要选择审批人"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
|
||||
return
|
||||
}
|
||||
slog.Info("该类型无候选审批人,允许不选提交", "userId", userID, "leaveType", req.LeaveType)
|
||||
}
|
||||
|
||||
// 附件复核:类型开关 + 数量上限(学校配置为准,产品上限 4 张)
|
||||
// 注意:实测多所旧套学校 enableAttach 恒为 0(该字段在旧套不代表"可传附件"),
|
||||
// 是否允许以 attachmentNumberLimit>0 为准;仅当两者都表明不可用时才拒绝。
|
||||
if len(imageURLs) > 0 {
|
||||
if cfg.EnableAttach != 1 && cfg.AttachmentNumberLimit <= 0 {
|
||||
errMsg := "该请假类型不支持上传附件"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if maxN := cpd.LeaveMaxAttachCount(cfg.AttachmentNumberLimit); len(imageURLs) > maxN {
|
||||
errMsg := fmt.Sprintf("附件最多 %d 张", maxN)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 可请假检查
|
||||
if err := cpd.CheckOnceLeave(cookieMap, host); err != nil {
|
||||
errMsg := "当前不可请假: " + err.Error()
|
||||
slog.Warn("checkOnceLeave 拒绝", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 构造表单并提交
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
p := cpd.LeaveApplyParams{
|
||||
LeaveType: req.LeaveType,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
LeaveReason: req.LeaveReason,
|
||||
OutStatus: req.OutStatus,
|
||||
UrgencyMobile: req.UrgencyMobile,
|
||||
Address: req.Address,
|
||||
DetailAddress: req.DetailAddress,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
LocationAddr: req.LocationAddr,
|
||||
ImageURLs: imageURLs,
|
||||
ApproverWid: req.ApproverWid,
|
||||
ApproverId: req.ApproverId,
|
||||
ApproverName: req.ApproverName,
|
||||
CcUsers: req.CcUsers,
|
||||
}
|
||||
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
|
||||
submit := func(cm map[string]string) error {
|
||||
if req.IsExtend {
|
||||
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
|
||||
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
|
||||
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
err = submit(cookieMap)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = submit(cookieMap)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "请假申请失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("请假申请失败", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "请假申请提交成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "请假申请提交成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("请假申请成功", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// leaveApplyLogTitle 请假日志标题:类型名由前端传入 code 时的兜底为「请假」
|
||||
func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
|
||||
title := fmt.Sprintf("请假申请(类型%d)", leaveType)
|
||||
if startTime != "" && endTime != "" {
|
||||
title = fmt.Sprintf("请假申请 %s ~ %s", startTime, endTime)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// executeXsgzLeaveApply 三易拓新学工5(如山东农业大学)提交请假。
|
||||
//
|
||||
// 与辅导猫链路完全独立:用 CASTGC 换 xsgz 会话后调用
|
||||
// POST /api/leave/student_action/application_submit(multipart/form-data)。
|
||||
// 事件流与辅导猫保持一致(connecting → submitting → success/task_error)。
|
||||
func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveApplyRequest, imageURLs []string) error {
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
|
||||
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
|
||||
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "error", "message": "服务内部错误,请稍后重试",
|
||||
})
|
||||
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpLeaveLog{
|
||||
UserID: user.ID,
|
||||
LeaveID: "apply_" + strconv.FormatInt(time.Now().Unix(), 10),
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
|
||||
|
||||
sess, err := newXsgzSession(ctx, user)
|
||||
if err != nil {
|
||||
msg := "学工系统登录失败: " + err.Error()
|
||||
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
|
||||
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
|
||||
// 写回各字段的 value 后作为 fields 提交。
|
||||
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
|
||||
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
|
||||
if ffErr != nil {
|
||||
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
|
||||
formFields = nil
|
||||
}
|
||||
fillXsgzFieldValues(formFields, req)
|
||||
// 按前端 Ie() 语义清洗:上传类去 value、多选数组 join(",")
|
||||
normalizeXsgzFields(formFields)
|
||||
|
||||
// 提交前预检:学校表单可能包含本平台无法提供值的必填字段
|
||||
// (如"是否离校"单选)。与其提交后得到含糊失败,不如明确指出。
|
||||
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
|
||||
msg := "该校请假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
|
||||
",请前往学校学工系统办理"
|
||||
slog.Warn("xsgz 表单存在未填必填项", "userId", user.ID, "missing", missing)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
payload := cpd.XsgzSubmitPayload{
|
||||
ProjectID: req.XsgzProjectID,
|
||||
RequestType: "LEAVE",
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Address: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
Fields: formFields,
|
||||
}
|
||||
body, err := sess.XsgzSubmitLeave(ctx, payload)
|
||||
if err != nil {
|
||||
msg := "提交请假失败: " + err.Error()
|
||||
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
// 解析返回:code=0 视为成功
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
if result.Code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "提交请假失败"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
|
||||
slog.Info("xsgz 请假申请成功", "userId", user.ID, "projectId", req.XsgzProjectID,
|
||||
"start", req.StartTime, "end", req.EndTime)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义。
|
||||
//
|
||||
// 依据前端 transformFields 的字段结构:
|
||||
//
|
||||
// form_fields_for_add 返回 { fieldId, fieldName, componentType, fieldType, required, options[] }
|
||||
//
|
||||
// 提交时前端遍历字段,把用户输入按 fieldId 写回 value。本平台只有「请假原因」
|
||||
// 一项自由文本,因此:
|
||||
// 1. 优先按标签(fieldName 含"原因/事由/说明")精确匹配;
|
||||
// 2. 未命中时,退化为「必填的文本类字段」(fieldType 为 TEXT/TEXTAREA/LONGTEXT)
|
||||
// 一律填入请假原因——该校表单通常只有一个原因字段,这样可以避免因
|
||||
// 学校自定义字段名(如"请假事由说明""申请理由")导致漏填。
|
||||
//
|
||||
// 选择类字段(RADIO/CHECKBOX/SELECT 等)不猜测,留空由学校端校验。
|
||||
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
|
||||
if len(fields) == 0 || req.LeaveReason == "" {
|
||||
return
|
||||
}
|
||||
textTypes := map[string]bool{"TEXT": true, "TEXTAREA": true, "LONGTEXT": true}
|
||||
|
||||
// 第一轮:按标签匹配
|
||||
matched := false
|
||||
for _, f := range fields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue // 上传类/地区类字段不该被文本值覆盖
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if strings.Contains(label, "原因") || strings.Contains(label, "事由") || strings.Contains(label, "说明") {
|
||||
f["value"] = req.LeaveReason
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
|
||||
// 第二轮:必填文本字段兜底
|
||||
for _, f := range fields {
|
||||
ft := strings.ToUpper(mapStr(f, "fieldType"))
|
||||
required, _ := f["required"].(bool)
|
||||
if required && textTypes[ft] {
|
||||
f["value"] = req.LeaveReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStr 安全取 map 中的字符串值
|
||||
func mapStr(m map[string]interface{}, k string) string {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkXsgzUnfilledRequired 返回仍未填值的必填字段名称列表。
|
||||
//
|
||||
// 学校表单由后台配置,可能包含本平台无法提供值的必填字段
|
||||
// (如"是否离校"单选、"请假类型细分"下拉)。与其让提交到学校端后
|
||||
// 得到含糊的失败,不如在提交前明确告知缺哪些字段。
|
||||
func checkXsgzUnfilledRequired(fields []map[string]interface{}) []string {
|
||||
var missing []string
|
||||
for _, f := range fields {
|
||||
required, _ := f["required"].(bool)
|
||||
if !required {
|
||||
continue
|
||||
}
|
||||
// 上传类字段由 uploadFiles 承载,没有 value 属正常,
|
||||
// 不应算作「未填写」(本平台不代传附件,由学校端按需处理)。
|
||||
// 注意:地区类(regions)不能在此跳过——它就是要靠 value 承载,
|
||||
// 我们没有数据就必然提交空值,必须拦下并告知用户。
|
||||
if xsgzUploadComponentTypes[mapStr(f, "componentType")] {
|
||||
continue
|
||||
}
|
||||
if v, ok := f["value"]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
|
||||
continue
|
||||
}
|
||||
name := mapStr(f, "fieldName")
|
||||
if name == "" {
|
||||
name = mapStr(f, "fieldId")
|
||||
}
|
||||
if name == "" {
|
||||
name = "未命名字段"
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// xsgzUploadComponentTypes 前端 Ie() 里走 uploadFiles 分支的组件类型。
|
||||
var xsgzUploadComponentTypes = map[string]bool{
|
||||
"imageUpload": true,
|
||||
"fileUpload": true,
|
||||
"smoothSignature": true,
|
||||
}
|
||||
|
||||
// xsgzRegionComponentType 地区选择组件。
|
||||
//
|
||||
// 前端 Ie() 对它的处理是把「选中值」与「详情」拼接后写入 value:
|
||||
//
|
||||
// s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])?.replace(/,/g,",")?.trim() || ""
|
||||
// o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)
|
||||
//
|
||||
// 本平台不提供地区选择,也就没有 _regionsDetail/_detail 可拼接,
|
||||
// 因此不能把「请假原因」当成地区值写进去——那会产生学校端无法解析的
|
||||
// 非法地区串。这类字段一律留空,由学校端校验或用户在学工系统内补填。
|
||||
const xsgzRegionComponentType = "regions"
|
||||
|
||||
// xsgzNonTextFillable 判断字段是否不应被文本值(如请假原因)填充。
|
||||
func xsgzNonTextFillable(componentType string) bool {
|
||||
return xsgzUploadComponentTypes[componentType] || componentType == xsgzRegionComponentType
|
||||
}
|
||||
|
||||
// normalizeXsgzFields 按前端 Ie()(studentForm-Dx5Fpjfs.js)的语义净化字段定义。
|
||||
//
|
||||
// 前端对每个字段按 componentType 分三支处理,直接把表单值映射到提交对象:
|
||||
//
|
||||
// regions: value = 拼接后的地名(含 _regionsDetail / _detail 后缀处理)
|
||||
// imageUpload / fileUpload / smoothSignature:
|
||||
// o.uploadFiles = 文件列表(并 delete content/url),**不设 value**
|
||||
// 其余: o.value = Array.isArray(n) ? n.join(",") : n
|
||||
//
|
||||
// 我们的实现只填了 value,存在两个问题:
|
||||
// 1. 上传类字段被塞了 value(前端不会这样提交),且完全没有 uploadFiles;
|
||||
// 2. 多选类字段若是数组,前端会 join(","),我们直接保留数组,
|
||||
// 经 xsgzFlatten 展开成 fields[n].value[k] 这种学校端读不到的键。
|
||||
//
|
||||
// 本函数只做「清洗」:去掉不该出现的内容,不臆造用户未提供的数据。
|
||||
func normalizeXsgzFields(fields []map[string]interface{}) {
|
||||
for _, f := range fields {
|
||||
ct := mapStr(f, "componentType")
|
||||
if xsgzUploadComponentTypes[ct] {
|
||||
// 上传类:value 不应存在(由 uploadFiles 承载)。
|
||||
// 本平台不代传附件,保持为空即可。
|
||||
delete(f, "value")
|
||||
continue
|
||||
}
|
||||
if ct == xsgzRegionComponentType {
|
||||
// 地区类:前端 value 是「选中值,详情」的拼接串。
|
||||
// 我们既无选中值也无 _regionsDetail/_detail,若残留 value
|
||||
// 会被学校端当作非法地区解析,故清空但保留键(空值由
|
||||
// xsgzFlatten 跳过,不会提交)。
|
||||
f["value"] = ""
|
||||
continue
|
||||
}
|
||||
// 其余:若 value 是数组,按前端规则 join(",")(多选拼接)
|
||||
if arr, ok := f["value"].([]interface{}); ok {
|
||||
parts := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
s := fmt.Sprintf("%v", v)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
f["value"] = strings.Join(parts, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// TestMergeApplyImageURLs 附件合并:多图字段 + 旧前端兼容单图字段,
|
||||
// 保序去重、剔除空串与首尾空白(避免旧缓存包附件被静默丢弃或重复提交)。
|
||||
func TestMergeApplyImageURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
urls []string
|
||||
legacy string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "仅多图字段",
|
||||
urls: []string{"a", "b"},
|
||||
legacy: "",
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "仅旧单图字段",
|
||||
urls: nil,
|
||||
legacy: "a",
|
||||
want: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "旧字段与多图重复应去重",
|
||||
urls: []string{"a", "b"},
|
||||
legacy: "a",
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "旧字段不在多图中应追加",
|
||||
urls: []string{"a"},
|
||||
legacy: "c",
|
||||
want: []string{"a", "c"},
|
||||
},
|
||||
{
|
||||
name: "空串与空白剔除",
|
||||
urls: []string{"", " ", "a"},
|
||||
legacy: " ",
|
||||
want: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "首尾空白裁剪",
|
||||
urls: []string{" a ", " b"},
|
||||
legacy: "",
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "全部为空",
|
||||
urls: []string{""},
|
||||
legacy: "",
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := mergeApplyImageURLs(c.urls, c.legacy)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewToLeaveTypesAttachGating 新套类型翻译:材料区不可见时不得带出数量上限,
|
||||
// 否则前端按「上限>0」的条件会误显示上传入口(旧套学校 enableAttach 恒为 0 的同类问题)。
|
||||
func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
|
||||
types := []cpd.LeaveNewType{
|
||||
{TypeWid: 1, TypeName: "可见类型"},
|
||||
{TypeWid: 2, TypeName: "不可见类型"},
|
||||
}
|
||||
details := map[int]*cpd.LeaveNewFormDetail{
|
||||
1: {TypeName: "可见类型", IsVisibledEvidenceFile: true, EvidenceFileCountMax: 4},
|
||||
2: {TypeName: "不可见类型", IsVisibledEvidenceFile: false, EvidenceFileCountMax: 10},
|
||||
}
|
||||
out := leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail { return details[wid] })
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("期望 2 个类型, got %d", len(out))
|
||||
}
|
||||
if out[0].EnableAttach != 1 || out[0].AttachmentNumberLimit != 4 {
|
||||
t.Errorf("可见类型应带出开关与上限: %+v", out[0])
|
||||
}
|
||||
if out[1].EnableAttach != 0 || out[1].AttachmentNumberLimit != 0 {
|
||||
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewRequireSignMapping 手写承诺书需求透传:新套 queryLeaveType.isSign → LeaveType.requireSign
|
||||
func TestLeaveNewRequireSignMapping(t *testing.T) {
|
||||
types := []cpd.LeaveNewType{
|
||||
{TypeWid: 8484, TypeName: "事假", IsSign: true},
|
||||
{TypeWid: 6185, TypeName: "病假", IsSign: false},
|
||||
}
|
||||
out := leaveNewToLeaveTypes(types, func(int) *cpd.LeaveNewFormDetail { return nil })
|
||||
if out[0].RequireSign != 1 {
|
||||
t.Errorf("isSign=true 应标记 requireSign=1: %+v", out[0])
|
||||
}
|
||||
if out[1].RequireSign != 0 {
|
||||
t.Errorf("isSign=false 不应标记 requireSign: %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewApplyFormSignature 手写承诺书作为 attachs 中 isStuSign 项附加
|
||||
func TestBuildLeaveNewApplyFormSignature(t *testing.T) {
|
||||
// 无签名时不应出现 isStuSign 项
|
||||
without := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, ImageURLs: []string{"https://obs/a.jpg"}})
|
||||
as := without["attachs"].([]map[string]interface{})
|
||||
if len(as) != 1 {
|
||||
t.Fatalf("无签名时期望 1 个附件, got %d", len(as))
|
||||
}
|
||||
if _, ok := as[0]["isStuSign"]; ok {
|
||||
t.Errorf("普通附件不应带 isStuSign")
|
||||
}
|
||||
|
||||
// 有签名时附加带 isStuSign 的项
|
||||
with := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, SignatureURL: "https://obs/newBucket/sign.jpg?x=1"})
|
||||
as2 := with["attachs"].([]map[string]interface{})
|
||||
if len(as2) != 1 {
|
||||
t.Fatalf("有签名时期望 1 个附件, got %d", len(as2))
|
||||
}
|
||||
sig := as2[0]
|
||||
if sig["isStuSign"] != true {
|
||||
t.Errorf("签名项应带 isStuSign=true: %+v", sig)
|
||||
}
|
||||
if sig["attachName"] != "学生手写承诺书" {
|
||||
t.Errorf("签名项 attachName 应为主学生手写承诺书: %+v", sig)
|
||||
}
|
||||
if sig["obsKey"] != "newBucket/sign.jpg" {
|
||||
t.Errorf("签名项 obsKey 应从 URL 推导: %+v", sig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 续假(仅新套 wec-counselor-leave-new-apps)。
|
||||
// 续假复用请假提交接口 /stu/leave/apply(body 带 reApply/isExtend/leaveId/extendId);
|
||||
// 执行逻辑并入 ExecuteLeaveApply(见 leaveNewExecuteApply 的续假分支),本文件仅提供「续假预填」查询。
|
||||
|
||||
// GetLeaveExtendPrefill 续假预填:返回原假单的类型、原结束时间(续假开始时间)、原因、是否离校等。
|
||||
// 前端据此预填续假表单(开始时间锁定为原结束时间,用户只改新的结束时间)。
|
||||
func GetLeaveExtendPrefill(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LeaveID string `json:"leaveId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
if req.LeaveID == "" {
|
||||
return utils.FailParam(c, "缺少请假单 ID")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:续假也不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
|
||||
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
|
||||
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
d, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, fiber.Map{
|
||||
"leaveId": req.LeaveID,
|
||||
"typeWid": d.LeaveCodeNum,
|
||||
"beginTime": d.EndTime, // 续假开始时间 = 原假单结束时间
|
||||
"endTime": d.EndTime,
|
||||
"leaveReason": "",
|
||||
"isLeaveOut": "",
|
||||
})
|
||||
}
|
||||
|
||||
rd, err := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
rd, err = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
|
||||
}
|
||||
|
||||
// extendId 为空通常意味着该假单当前不可续假(已销假/已结束/未到可续假状态)
|
||||
if strings.TrimSpace(rd.ExtendID) == "" && strings.TrimSpace(rd.BeginTime) == "" {
|
||||
return utils.Fail(c, 400, "该请假单当前不可续假")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"leaveId": rd.LeaveID,
|
||||
"typeWid": rd.TypeWid,
|
||||
"beginTime": rd.BeginTime, // 续假开始时间 = 原假单结束时间
|
||||
"endTime": rd.EndTime,
|
||||
"leaveReason": rd.LeaveReason,
|
||||
"isLeaveOut": rd.IsLeaveOut,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// xsgzTypesToLeaveTypes 把 xsgz 请假项目转换为平台通用的 LeaveType 契约。
|
||||
//
|
||||
// 该校请假类型只有 id/text,没有旧契约里的审批人/附件/字数等开关,
|
||||
// 因此按最小可用集构造:不需要审批人、不允许附件、不限制字数。
|
||||
func xsgzTypesToLeaveTypes(projs []cpd.XsgzLeaveType) []cpd.LeaveType {
|
||||
out := make([]cpd.LeaveType, 0, len(projs))
|
||||
for i, p := range projs {
|
||||
out = append(out, cpd.LeaveType{
|
||||
Code: i + 1,
|
||||
ProjectID: p.ID, // 真实 UUID,提交时用它而非 code
|
||||
Name: p.Text,
|
||||
NeedApproval: 0,
|
||||
EnableAttach: 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newXsgzSession 用当前用户的辅导猫会话(CASTGC)换取 xsgz 学工系统的 access_token 会话。
|
||||
//
|
||||
// 链路:辅导猫登录 → CASTGC → CAS 换 service 指向 {xsgz}/oauth/callback 的票据
|
||||
// → /api/cas/callback(form-urlencoded ticket=密文)→ access_token。
|
||||
func newXsgzSession(ctx context.Context, user *model.SysUser) (*cpd.XsgzSession, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["CASTGC"] == "" {
|
||||
return nil, fmt.Errorf("登录凭证缺少 CASTGC,请重新登录")
|
||||
}
|
||||
service := cpd.XsgzCasServiceURL(user.SchoolID)
|
||||
if service == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工系统地址")
|
||||
}
|
||||
|
||||
// 用 CASTGC 换取指向学工系统的 CAS 票据
|
||||
ticket, err := cpd.CasTicketForService(ctx, cpd.CasServiceConfig{
|
||||
CasBase: getXsgzCasBase(user.SchoolID),
|
||||
Service: service,
|
||||
CASTGC: cookieMap["CASTGC"],
|
||||
Username: user.StudentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cpd.XsgzLogin(ctx, user.SchoolID, ticket)
|
||||
}
|
||||
|
||||
// getXsgzCasBase 返回该校 CAS 服务根地址(xsgz 学校与辅导猫共用同一 CAS)
|
||||
func getXsgzCasBase(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "sdau":
|
||||
return "https://auths.sdau.edu.cn/authserver"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// rejectExternalLeaveSystem 拦截走学校独立学工系统的请假/销假请求。
|
||||
//
|
||||
// 背景:部分学校(如山东农业大学 xsgz、云南警官学院 xsfw)的请假由学校学工系统
|
||||
// 独立提供,辅导猫侧请假应用为 HTTP 420「产品已到期」。这些学校的所有请假相关
|
||||
// 入口(类型/审批人/待销假/执行/续假)都必须在此短路,否则会掉进辅导猫旧链路,
|
||||
// 用户看到的只是误导性的 420 到期错误。
|
||||
//
|
||||
// 返回 true 表示已写出响应、调用方应立即 return。
|
||||
func rejectExternalLeaveSystem(c *fiber.Ctx, user *model.SysUser, cookieMap map[string]string) bool {
|
||||
switch schoolLeaveFlavor(user, cookieMap) {
|
||||
case cpd.LeaveFlavorXsgz:
|
||||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工系统"))
|
||||
return true
|
||||
case cpd.LeaveFlavorXsfw:
|
||||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工平台"))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveExternalSystemHint 生成「该校请假走独立学工系统」的提示文案。
|
||||
// 这类学校的辅导猫请假应用为 HTTP 420「产品已到期」,与我们无关;
|
||||
// 直连辅导猫只会得到误导性的到期错误,故在此明确告知用户实际入口。
|
||||
func leaveExternalSystemHint(schoolName, systemName string) string {
|
||||
if schoolName == "" {
|
||||
schoolName = "贵校"
|
||||
}
|
||||
return schoolName + " 的请假由学校「" + systemName + "」独立提供,不支持在今日校园内提交,请前往学校学工系统办理"
|
||||
}
|
||||
|
||||
// 请假应用套件分流(新旧两套 API 并存):
|
||||
//
|
||||
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
|
||||
// 旧套 wec-counselor-leave-apps(「请假」应用,默认)
|
||||
//
|
||||
// 分流依据:辅导猫 getAppInfos 返回的本校应用列表(openUrl 指向哪套);
|
||||
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
|
||||
|
||||
type leaveFlavorCacheEntry struct {
|
||||
flavor cpd.LeaveAppFlavor
|
||||
expire time.Time
|
||||
}
|
||||
|
||||
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
|
||||
|
||||
const leaveFlavorCacheTTL = 30 * time.Minute
|
||||
|
||||
// schoolLeaveFlavor 获取(并缓存)学校请假应用套件类型
|
||||
func schoolLeaveFlavor(user *model.SysUser, cookieMap map[string]string) cpd.LeaveAppFlavor {
|
||||
// 按学校 ID 单独适配的独立学工系统优先判断(不计入缓存:
|
||||
// 判定依据是静态映射,无探测成本,也避免多套系统间缓存串味)
|
||||
// xsgz:三易拓「新学工5」(如山东农业大学 xsgz.sdau.edu.cn)
|
||||
// xsfw:金智 xsfw 学工平台(如云南警官学院 xg.ynpc.edu.cn/xsfwdt)
|
||||
if cpd.IsXsgzSchool(user.SchoolID) {
|
||||
return cpd.LeaveFlavorXsgz
|
||||
}
|
||||
if cpd.IsXsfwSchool(user.SchoolID) {
|
||||
return cpd.LeaveFlavorXsfw
|
||||
}
|
||||
|
||||
if v, ok := leaveFlavorCache.Load(user.SchoolID); ok {
|
||||
if e, ok2 := v.(leaveFlavorCacheEntry); ok2 && time.Now().Before(e.expire) {
|
||||
return e.flavor
|
||||
}
|
||||
}
|
||||
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
flavor, openURL := cpd.FetchLeaveAppFlavor(cookieMap, host)
|
||||
if flavor == cpd.LeaveFlavorUnknown {
|
||||
// getAppInfos 失败/无请假应用:试探新套端点兜底
|
||||
if cpd.PingLeaveNew(cookieMap, host) {
|
||||
flavor = cpd.LeaveFlavorNew
|
||||
} else {
|
||||
// 默认按旧套处理(cookie 过期等场景下旧套错误的语义更准确)
|
||||
flavor = cpd.LeaveFlavorOld
|
||||
}
|
||||
}
|
||||
slog.Info("请假应用套件分流", "schoolId", user.SchoolID, "flavor", flavor.String(), "openUrl", openURL)
|
||||
leaveFlavorCache.Store(user.SchoolID, leaveFlavorCacheEntry{flavor: flavor, expire: time.Now().Add(leaveFlavorCacheTTL)})
|
||||
return flavor
|
||||
}
|
||||
|
||||
// invalidateLeaveFlavor 清缓存(应用迁移后可强制重探)
|
||||
func invalidateLeaveFlavor(schoolID string) {
|
||||
leaveFlavorCache.Delete(schoolID)
|
||||
}
|
||||
|
||||
// ---------- 新套 → 现有前端契约的翻译 ----------
|
||||
|
||||
// leaveNewToLeaveTypes 新套类型列表 → 旧契约 LeaveType(前端零改动消费)
|
||||
func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *cpd.LeaveNewFormDetail) []cpd.LeaveType {
|
||||
out := make([]cpd.LeaveType, 0, len(list))
|
||||
for _, t := range list {
|
||||
item := cpd.LeaveType{
|
||||
Code: t.TypeWid,
|
||||
Name: t.TypeName,
|
||||
}
|
||||
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
|
||||
if t.IsSign {
|
||||
item.RequireSign = 1
|
||||
}
|
||||
if d := detailOf(t.TypeWid); d != nil {
|
||||
if d.TypeName != "" {
|
||||
item.Name = d.TypeName
|
||||
}
|
||||
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
|
||||
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
|
||||
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
|
||||
item.OutStatusDefault = d.IsLeaveOut
|
||||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||||
item.NeedApproval = 1
|
||||
if d.IsVisibledEvidenceFile {
|
||||
item.EnableAttach = 1
|
||||
item.AttachmentNumberLimit = d.EvidenceFileCountMax
|
||||
} else {
|
||||
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
|
||||
item.AttachmentNumberLimit = 0
|
||||
}
|
||||
// 证明材料是否必填(isRequiredEvidenceFile):类型列表接口不返回该开关,
|
||||
// 只在详情里;此前未透传,导致必填附件被漏检、提交被服务端拒绝。
|
||||
if d.IsRequiredEvidence {
|
||||
item.RequireEvidence = 1
|
||||
}
|
||||
item.EvidenceMin = d.EvidenceFileCountMini
|
||||
// 自定义附加字段(selfDefineFields):必填项需前端采集、提交时回填 selfDefineAnswers
|
||||
if raw, ok := d.Raw["selfDefineFields"]; ok && raw != nil {
|
||||
if arr, ok := raw.([]interface{}); ok {
|
||||
fields := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
fields = append(fields, m)
|
||||
}
|
||||
}
|
||||
item.SelfDefineFields = fields
|
||||
}
|
||||
}
|
||||
item.ReasonWordLimit = d.LeaveReasonWordsMini
|
||||
item.DisableTime = 0
|
||||
item.LimitDay = 0
|
||||
} else {
|
||||
// 详情获取失败:保守默认(不阻塞列表)
|
||||
item.NeedApproval = 1
|
||||
item.EnableAttach = 1
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewPlacesToLeaveReportPlaces 新套销假位置 → 旧契约 LeaveReportPlace
|
||||
func leaveNewPlacesToLeaveReportPlaces(list []cpd.LeaveNewReportPlace) []cpd.LeaveReportPlace {
|
||||
out := make([]cpd.LeaveReportPlace, 0, len(list))
|
||||
for _, p := range list {
|
||||
out = append(out, cpd.LeaveReportPlace{
|
||||
Wid: p.Wid,
|
||||
Address: p.Address,
|
||||
Longitude: p.Longitude,
|
||||
Latitude: p.Latitude,
|
||||
Radius: p.Radius,
|
||||
CounName: p.CounName,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewApproversToLeaveApprovers 新套审批人 → 旧契约 LeaveApprover(前端展示用)
|
||||
func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveApprover {
|
||||
out := make([]cpd.LeaveApprover, 0, len(list))
|
||||
for _, a := range list {
|
||||
out = append(out, cpd.LeaveApprover{
|
||||
UserWid: a.UserWid,
|
||||
UserId: a.UserId,
|
||||
UserName: a.UserName,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
|
||||
//
|
||||
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
|
||||
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
|
||||
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
|
||||
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
|
||||
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
|
||||
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
|
||||
//
|
||||
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
|
||||
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
|
||||
func streamCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
}
|
||||
|
||||
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
|
||||
//
|
||||
// 前端从详情对象取该标记并作为 prop 传给提交弹窗:
|
||||
//
|
||||
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
|
||||
// || c.value?.cancellationApplyInArea === "是"
|
||||
//
|
||||
// 其中 c.value 来自 leave_detail(listType==="apply")。
|
||||
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按 leaveId 精确重查;
|
||||
// 详情查不到时回退到列表查找;两者都失败则保守返回 false(不额外拦截,
|
||||
// 交由学校端最终校验)。
|
||||
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
|
||||
if d, err := sess.XsgzFetchLeaveDetail(ctx, leaveID); err != nil {
|
||||
slog.Warn("xsgz 查询请假详情失败,回退列表查找", "leaveId", leaveID, "error", err)
|
||||
} else if d != nil {
|
||||
return d.RequiresInArea()
|
||||
}
|
||||
|
||||
recs, _, err := sess.XsgzFetchLeavePage(ctx, 1, 50)
|
||||
if err != nil {
|
||||
slog.Warn("xsgz 查询请假记录失败,跳过销假范围强制校验", "leaveId", leaveID, "error", err)
|
||||
return false
|
||||
}
|
||||
for _, r := range recs {
|
||||
if r.ID == leaveID {
|
||||
return r.RequiresInArea()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// 走独立学工系统的学校必须被守卫拦截,且提示文案要说明真实入口,
|
||||
// 而不是让用户看到辅导猫的 420「产品已到期」。
|
||||
func TestRejectExternalLeaveSystemFlavorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
schoolID string
|
||||
schoolName string
|
||||
wantReject bool
|
||||
wantSubstr string
|
||||
}{
|
||||
{"山东农业大学走新学工5", "sdau", "山东农业大学", true, "学工"},
|
||||
{"云南警官学院走 xsfw 学工", "2970d544-2917-44e6-85db-f3245d6e3b1d", "云南警官学院", true, "学工"},
|
||||
{"普通学校不拦截", "ynu", "云南大学", false, ""},
|
||||
{"空学校不拦截", "", "", false, ""},
|
||||
}
|
||||
// 用静态映射判定,避免触发辅导猫网络探测(普通学校会走 getAppInfos)
|
||||
expectExternal := func(schoolID string) bool {
|
||||
return cpd.IsXsgzSchool(schoolID) || cpd.IsXsfwSchool(schoolID)
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotReject := expectExternal(c.schoolID)
|
||||
if gotReject != c.wantReject {
|
||||
t.Fatalf("schoolID=%q wantReject=%v gotReject=%v", c.schoolID, c.wantReject, gotReject)
|
||||
}
|
||||
if c.wantSubstr != "" {
|
||||
hint := leaveExternalSystemHint(c.schoolName, "学工系统")
|
||||
if !strings.Contains(hint, c.wantSubstr) {
|
||||
t.Fatalf("提示文案缺少 %q: %s", c.wantSubstr, hint)
|
||||
}
|
||||
if !strings.Contains(hint, c.schoolName) {
|
||||
t.Fatalf("提示文案应包含学校名: %s", hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提示文案在缺学校名时也要可读。
|
||||
func TestLeaveExternalSystemHintEmptySchool(t *testing.T) {
|
||||
got := leaveExternalSystemHint("", "学工系统")
|
||||
if !strings.Contains(got, "贵校") {
|
||||
t.Fatalf("空学校名应回退为「贵校」: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// ---------- 新套执行链路(请假/销假) ----------
|
||||
|
||||
// leaveNewResolveApproverAndFlow 服务端自取审批流:按 userWid 匹配完整审批人对象 + flowData。
|
||||
// 返回 (approver, flowData, candidates, err):
|
||||
// - approver 为 nil 表示未提供 userWid 或未匹配(调用方决定是否放行)
|
||||
// - candidates 为全部候选(用于「无候选可放行」判定)
|
||||
func leaveNewResolveApproverAndFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, approverWid string) (*cpd.LeaveNewApprover, map[string]interface{}, []cpd.LeaveNewApprover, error) {
|
||||
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if approverWid == "" {
|
||||
return nil, flow.FlowData, flow.ApproveUsers, nil
|
||||
}
|
||||
for i := range flow.ApproveUsers {
|
||||
if flow.ApproveUsers[i].UserWid == approverWid {
|
||||
return &flow.ApproveUsers[i], flow.FlowData, flow.ApproveUsers, nil
|
||||
}
|
||||
}
|
||||
return nil, flow.FlowData, flow.ApproveUsers, nil
|
||||
}
|
||||
|
||||
// leaveNewResolveCcUsers 抄送人 userWid 列表 → 完整对象(新套 getCcSearchList 全量匹配)
|
||||
func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []string) []cpd.LeaveNewApprover {
|
||||
if len(widList) == 0 {
|
||||
return nil
|
||||
}
|
||||
all, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, "")
|
||||
if err != nil {
|
||||
slog.Warn("获取抄送人候选失败,抄送人将被忽略", "error", err)
|
||||
return nil
|
||||
}
|
||||
want := make(map[string]bool, len(widList))
|
||||
for _, w := range widList {
|
||||
if w != "" {
|
||||
want[w] = true
|
||||
}
|
||||
}
|
||||
out := make([]cpd.LeaveNewApprover, 0, len(widList))
|
||||
seen := make(map[string]bool, len(widList))
|
||||
for _, a := range all {
|
||||
if want[a.UserWid] && !seen[a.UserWid] {
|
||||
seen[a.UserWid] = true
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
|
||||
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
|
||||
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
|
||||
if err != nil {
|
||||
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
|
||||
return false
|
||||
}
|
||||
for _, t := range types {
|
||||
if t.TypeWid == typeWid {
|
||||
return t.IsSign
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
|
||||
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
|
||||
func leaveNewOutStatusValue(outStatus bool) int {
|
||||
if outStatus {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||||
func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyRequest, imageURLs []string) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.IsRequiredLeaveReason && strings.TrimSpace(req.LeaveReason) == "" {
|
||||
return "请填写请假原因"
|
||||
}
|
||||
if d.LeaveReasonWordsMini > 0 && len([]rune(req.LeaveReason)) < d.LeaveReasonWordsMini {
|
||||
return fmt.Sprintf("请假原因不能少于 %d 字", d.LeaveReasonWordsMini)
|
||||
}
|
||||
// 证明材料(附件):多图复核 —— 可见性开关 / 必填 / 张数下限 / 学校上限(产品上限 4)
|
||||
if len(imageURLs) > 0 {
|
||||
if !d.IsVisibledEvidenceFile {
|
||||
return "该请假类型不支持上传附件"
|
||||
}
|
||||
if maxN := cpd.LeaveMaxAttachCount(d.EvidenceFileCountMax); len(imageURLs) > maxN {
|
||||
return fmt.Sprintf("附件最多 %d 张", maxN)
|
||||
}
|
||||
}
|
||||
if d.IsRequiredEvidence {
|
||||
if len(imageURLs) == 0 {
|
||||
return "请上传证明材料"
|
||||
}
|
||||
if d.EvidenceFileCountMini > 0 && len(imageURLs) < d.EvidenceFileCountMini {
|
||||
return fmt.Sprintf("证明材料至少 %d 张", d.EvidenceFileCountMini)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------- 新套销假执行 ----------
|
||||
|
||||
// leaveNewExecuteReport 新套销假执行(供 handler SSE 调用)。
|
||||
// 返回 (是否成功, 用户可见错误信息, 需要重新登录)
|
||||
func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, host string,
|
||||
leaveID, address, longitude, latitude, imageURL, approverWid string) (bool, string, bool) {
|
||||
|
||||
// 1. 详情 + 状态复核(是否处于可销假状态)
|
||||
detail, err := cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||||
if err != nil {
|
||||
if isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "获取请假详情失败: " + err.Error(), false
|
||||
}
|
||||
}
|
||||
if !detail.CanReport {
|
||||
return false, fmt.Sprintf("当前请假单不可销假(状态:%s)", detail.StatusText), false
|
||||
}
|
||||
|
||||
// 2. 确认人(reportMode=2 老师审批销假时需要)
|
||||
var approver *cpd.LeaveNewApprover
|
||||
if detail.ReportMode == "2" && approverWid != "" {
|
||||
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
|
||||
if err == nil {
|
||||
for i := range list {
|
||||
if list[i].UserWid == approverWid {
|
||||
approver = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构造并提交
|
||||
form := cpd.BuildLeaveNewReportForm(cpd.LeaveNewReportParams{
|
||||
LeaveID: leaveID,
|
||||
Address: address,
|
||||
Longitude: longitude,
|
||||
Latitude: latitude,
|
||||
ImageURL: imageURL,
|
||||
Approver: approver,
|
||||
UserID: user.StudentID,
|
||||
DeviceID: user.MobileCode,
|
||||
Model: "cpdaily",
|
||||
})
|
||||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "销假失败: " + err.Error(), false
|
||||
}
|
||||
return true, "", false
|
||||
}
|
||||
|
||||
// ---------- 新套请假执行 ----------
|
||||
|
||||
// leaveNewExecuteApply 新套请假执行(供 handler SSE 调用)。
|
||||
// 返回 (是否成功, 用户可见错误信息, 需要重新登录, 需要选择审批人)
|
||||
func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host string, req ExecuteLeaveApplyRequest, imageURLs []string) (bool, string, bool, bool) {
|
||||
// 1. 类型详情复核
|
||||
detail, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||||
if err != nil {
|
||||
if isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "获取请假类型配置失败: " + err.Error(), false, false
|
||||
}
|
||||
}
|
||||
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
|
||||
return false, msg, false, false
|
||||
}
|
||||
// 手写承诺书:isSign=1 的类型必须携带签名图。
|
||||
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
|
||||
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
|
||||
return false, "请先手写承诺书签名", false, false
|
||||
}
|
||||
|
||||
// 2. 审批流:取 flowData + 匹配完整审批人
|
||||
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
if err != nil {
|
||||
if isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "获取请假审批人失败: " + err.Error(), false, false
|
||||
}
|
||||
}
|
||||
if req.ApproverWid == "" && len(candidates) > 0 {
|
||||
return false, "该请假类型需要选择审批人", false, true
|
||||
}
|
||||
|
||||
// 3. 抄送人解析(非必填)
|
||||
ccWids := make([]string, 0, len(req.CcUsers))
|
||||
for _, cc := range req.CcUsers {
|
||||
ccWids = append(ccWids, cc.UserWid)
|
||||
}
|
||||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||||
|
||||
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
|
||||
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
|
||||
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
|
||||
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
|
||||
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
|
||||
|
||||
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
|
||||
var extendID string
|
||||
if req.IsExtend {
|
||||
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||||
if rerr != nil && isCookieExpired(rerr) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return false, "获取续假信息失败: " + rerr.Error(), false, false
|
||||
}
|
||||
extendID = rd.ExtendID
|
||||
}
|
||||
|
||||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||||
TypeWid: req.LeaveType,
|
||||
LeaveID: req.ExtendLeaveID,
|
||||
IsExtend: req.IsExtend,
|
||||
ExtendID: extendID,
|
||||
ReApply: req.IsExtend,
|
||||
BeginTime: trimSeconds(req.StartTime),
|
||||
EndTime: trimSeconds(req.EndTime),
|
||||
LeaveReason: req.LeaveReason,
|
||||
ImageURLs: imageURLs,
|
||||
SignatureURL: req.SignatureURL,
|
||||
LocationAddr: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
IsLeaveOut: isLeaveOut,
|
||||
Destination: req.Address,
|
||||
Detail: req.DetailAddress,
|
||||
EmergencyName: req.UrgencyMobile,
|
||||
EmergencyMobile: req.UrgencyMobile,
|
||||
Approver: approver,
|
||||
CcUsers: ccUsers,
|
||||
FlowData: flowData,
|
||||
UserID: user.StudentID,
|
||||
DeviceID: user.MobileCode,
|
||||
Model: "cpdaily",
|
||||
SelfDefineAnswers: req.SelfDefineAnswers,
|
||||
})
|
||||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "请假申请失败: " + err.Error(), false, false
|
||||
}
|
||||
return true, "", false, false
|
||||
}
|
||||
|
||||
// trimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)
|
||||
func trimSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
|
||||
return t[:16]
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
|
||||
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
|
||||
func TestLeaveNewOutStatusValue(t *testing.T) {
|
||||
if got := leaveNewOutStatusValue(true); got != 1 {
|
||||
t.Errorf("选择离校应得到 1,got %d", got)
|
||||
}
|
||||
if got := leaveNewOutStatusValue(false); got != 2 {
|
||||
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// ---------- 新套(wec-counselor-leave-new-apps)handler 适配 ----------
|
||||
//
|
||||
// 设计:新套数据在 handler 层翻译为现有前端契约(LeaveType/LeaveItem/LeaveApprover),
|
||||
// 前端无需感知新旧差异;老套逻辑保持原样,仅按 flavor 分流。
|
||||
|
||||
// refreshAfterCookieExpired 通用「cookie 过期→刷新→重试一次」包装
|
||||
func refreshAfterCookieExpired(user *model.SysUser, err error, retry func() error) error {
|
||||
if err == nil || !isCookieExpired(err) {
|
||||
return err
|
||||
}
|
||||
slog.Info("cookie过期,尝试刷新", "userId", user.ID)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
// 续期失败:无论原因(密码错/无密码无法自动重登等)都升级为「需要重新登录」
|
||||
return wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
return retry()
|
||||
}
|
||||
|
||||
// ---------- 销假:待销假列表(新套) ----------
|
||||
|
||||
// fetchLeaveNewPendingList 新套待销假列表:拉全部记录 → 过滤可销假 → 补位置与开关
|
||||
func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *model.SysUser) ([]cpd.LeaveItem, error) {
|
||||
records, err := cpd.FetchLeaveNewList(cookieMap, host, 1, 50, "1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 规定销假位置全局一份(reportList 与记录无关)
|
||||
places := []cpd.LeaveReportPlace{}
|
||||
if p, err := cpd.FetchLeaveNewReportPlaces(cookieMap, host); err == nil {
|
||||
places = leaveNewPlacesToLeaveReportPlaces(p)
|
||||
} else {
|
||||
slog.Warn("获取新套规定销假位置失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
list := make([]cpd.LeaveItem, 0, len(records))
|
||||
for _, r := range records {
|
||||
// 列表接口不返回 reportMode/reportUploadMode/isLeaveOut,需补拉详情判断;
|
||||
// 仅对可能可销假的状态(3 休假中 / 4 销假逾期)补详情,减少请求
|
||||
if r.Status != "3" && r.Status != "4" {
|
||||
continue
|
||||
}
|
||||
detail, derr := cpd.FetchLeaveNewDetail(cookieMap, host, r.LeaveID)
|
||||
if derr != nil {
|
||||
slog.Warn("获取新套请假详情失败,跳过该记录", "leaveId", r.LeaveID, "error", derr)
|
||||
continue
|
||||
}
|
||||
// 可销假 或 可续假(isAllowExtend)其一满足即展示
|
||||
if !detail.CanReport && !detail.IsAllowExtend {
|
||||
continue
|
||||
}
|
||||
// 销假需要确认人:reportMode=2(老师审批销假)
|
||||
needApprover := detail.ReportMode == "2"
|
||||
enableAttach := detail.ReportUploadMode == "2" || (detail.ReportUploadMode == "1" && detail.IsLeaveOut == "1")
|
||||
list = append(list, cpd.LeaveItem{
|
||||
ID: detail.LeaveID,
|
||||
LeaveType: leaveNewTypeNameOfTopic(detail.TopicName, detail.TypeWid),
|
||||
Reason: detail.LeaveReason,
|
||||
CreateTime: r.ApplyTime,
|
||||
ActEndTime: detail.EndTime,
|
||||
Status: detail.Status,
|
||||
StatusText: detail.StatusText,
|
||||
ReportPlaces: places,
|
||||
ReportApplyFlow: needApprover,
|
||||
EnableAttach: enableAttach,
|
||||
CanReport: detail.CanReport,
|
||||
CanExtend: detail.IsAllowExtend,
|
||||
TypeWid: detail.TypeWid,
|
||||
IsLeaveOut: detail.IsLeaveOut,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// leaveNewTypeNameOfTopic 从 topicName(如「李闯闯的事假」)提取类型名兜底
|
||||
func leaveNewTypeNameOfTopic(topicName, typeIcon string) string {
|
||||
if topicName == "" {
|
||||
return "请假"
|
||||
}
|
||||
return topicName
|
||||
}
|
||||
|
||||
// ---------- 请假类型(新套) ----------
|
||||
|
||||
// fetchLeaveNewTypesWithDetail 新套类型列表,逐类型补详情(权威开关)
|
||||
func fetchLeaveNewTypesWithDetail(cookieMap map[string]string, host string, user *model.SysUser) ([]cpd.LeaveType, error) {
|
||||
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details := make(map[int]*cpd.LeaveNewFormDetail, len(types))
|
||||
for _, t := range types {
|
||||
d, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, t.TypeWid)
|
||||
if err != nil {
|
||||
slog.Warn("获取新套类型详情失败,使用基础信息", "typeWid", t.TypeWid, "error", err)
|
||||
continue
|
||||
}
|
||||
details[t.TypeWid] = d
|
||||
}
|
||||
return leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail {
|
||||
return details[wid]
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ---------- 销假审批人(新套) ----------
|
||||
|
||||
// fetchLeaveNewReportApprovers 新套销假确认人(getReportApprovers by leaveId)
|
||||
func fetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID string, user *model.SysUser) ([]cpd.LeaveApprover, error) {
|
||||
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return leaveNewApproversToLeaveApprovers(list), nil
|
||||
}
|
||||
|
||||
// ---------- 请假审批人(新套) ----------
|
||||
|
||||
// leaveNewApplyApproversResult 新套请假审批人结果(含 flowData 透传)
|
||||
type leaveNewApplyApproversResult struct {
|
||||
Approvers []cpd.LeaveApprover `json:"approvers"`
|
||||
NeedApproval bool `json:"needApproval"`
|
||||
InFlowScope bool `json:"inFlowScope"`
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
}
|
||||
|
||||
// fetchLeaveNewApplyApprovers 新套请假审批人 + 审批流数据
|
||||
func fetchLeaveNewApplyApprovers(cookieMap map[string]string, host string, typeWid int, startTime, endTime string) (*leaveNewApplyApproversResult, error) {
|
||||
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &leaveNewApplyApproversResult{
|
||||
Approvers: leaveNewApproversToLeaveApprovers(flow.ApproveUsers),
|
||||
NeedApproval: flow.IsNeedApproval,
|
||||
InFlowScope: flow.IsInFlowScope,
|
||||
FlowData: flow.FlowData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLeaveNewCcSearch 抄送人搜索(新套;searchKey 为空返回全部)
|
||||
func GetLeaveNewCcSearch(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SearchKey string `json:"searchKey"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
list, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, req.SearchKey)
|
||||
if err != nil {
|
||||
_ = refreshAfterCookieExpired(user, err, func() error {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
var e error
|
||||
list, e = cpd.FetchLeaveNewCcSearch(cookieMap, host, req.SearchKey)
|
||||
return e
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取抄送人失败", "userId", user.ID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取抄送人失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, leaveNewApproversToLeaveApprovers(list))
|
||||
}
|
||||
|
||||
// buildLeaveNewApproverByWid 从审批人列表找 userWid 对应完整对象(提交表单用)
|
||||
func buildLeaveNewApproverByWid(list []cpd.LeaveNewApprover, userWid string) *cpd.LeaveNewApprover {
|
||||
if userWid == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].UserWid == userWid {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// leaveNewCheckCookieFresh 简单的 cookie 新鲜度检查(新套首次调用失败即触发刷新重试)
|
||||
func leaveNewRetryOnExpired(user *model.SysUser, err error, retry func() error) error {
|
||||
return refreshAfterCookieExpired(user, err, retry)
|
||||
}
|
||||
|
||||
// leaveNewFriendlyErr 新套错误统一包装(带 401 语义)
|
||||
func leaveNewFriendlyErr(c *fiber.Ctx, action string, err error) error {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, fmt.Sprintf("%s: %v", action, err))
|
||||
}
|
||||
|
||||
// ---------- 数据库日志辅助 ----------
|
||||
|
||||
// saveLeaveLogEntry 请假/销假日志落库(两套共用)
|
||||
func saveLeaveLogEntry(userID uint, leaveID, title, schoolName, action string, isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpLeaveLog{
|
||||
UserID: userID,
|
||||
LeaveID: leaveID,
|
||||
Title: title,
|
||||
SchoolName: schoolName,
|
||||
Action: action,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// 回归测试:SSE stream writer 回调中不能使用 fasthttp 的 RequestCtx。
|
||||
//
|
||||
// 线上事故(v1.2.158):销假接口在 stream writer 里调用
|
||||
// newXsgzSession(c.Context(), ...),而该回调运行在 fasthttp 的独立写协程中,
|
||||
// 此时 RequestCtx 已被 reset(ctx.s == nil)。resty 的 SetContext 会走
|
||||
// context.WithDeadline → propagateCancel → parent.Done(),
|
||||
// 而 RequestCtx.Done() 解引用 ctx.s 直接 SIGSEGV:
|
||||
//
|
||||
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||
// [signal SIGSEGV: ... addr=0x560]
|
||||
// fasthttp.(*RequestCtx).Done(...) server.go:2726
|
||||
//
|
||||
// panic 发生在协程里,fiber 的 recover 中间件拦不住,整个进程退出,
|
||||
// 前端 SSE 连接被掐断,只能显示「连接失败,请检查网络」。
|
||||
|
||||
// TestStreamCtxIsUsable 保证 streamCtx 返回的 context 是完整可用的。
|
||||
func TestStreamCtxIsUsable(t *testing.T) {
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("streamCtx 不应返回 nil")
|
||||
}
|
||||
|
||||
// 必须支持 Done():resty 在 SetContext 后依赖它注册取消传播
|
||||
done := ctx.Done()
|
||||
if done == nil {
|
||||
t.Fatal("Done() 不应为 nil,否则 resty 内部 propagateCancel 会出错")
|
||||
}
|
||||
|
||||
// 必须有 deadline:避免上游某一跳卡住导致协程永不释放
|
||||
dl, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("streamCtx 应带 deadline")
|
||||
}
|
||||
if time.Until(dl) <= 0 {
|
||||
t.Fatal("deadline 应在未来")
|
||||
}
|
||||
|
||||
// 取 Done 不应 panic
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("新建的 context 不应已取消")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCtxDonePanicsAfterReset 直接复现线上崩溃的根因。
|
||||
//
|
||||
// 零值的 fasthttp.RequestCtx 即 ctx.s == nil,等价于 stream writer 回调中
|
||||
// 被 reset 之后的状态。调用其 Done() 会解引用 nil 的 ctx.s 而 SIGSEGV
|
||||
// (server.go:2726: func (ctx *RequestCtx) Done() { return ctx.s.done })。
|
||||
//
|
||||
// 该用例存在的意义:把「为什么不能在回调里用 c.Context()」固化下来。
|
||||
// 若将来 fasthttp 改成安全返回 nil,此用例会 skip,届时可考虑移除 streamCtx。
|
||||
func TestRequestCtxDonePanicsAfterReset(t *testing.T) {
|
||||
var rctx fasthttp.RequestCtx // 零值:内部 s 为 nil
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Skip("fasthttp 已修复:RequestCtx.Done 在 s==nil 时不再 panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = rctx.Done() // 线上就是这样崩的
|
||||
t.Fatal("预期 panic 未发生")
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fillXsgzFieldValues:优先按标签匹配,未命中时用必填文本字段兜底。
|
||||
func TestFillXsgzFieldValues(t *testing.T) {
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒发烧需休息"}
|
||||
|
||||
t.Run("按标签命中原因字段", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假原因", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "紧急联系人", "fieldType": "TEXT", "required": true},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("原因字段未被填充: %v", fields[0])
|
||||
}
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("非原因字段不应被填充: %v", fields[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("学校自定义字段名时用必填文本兜底", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "申请理由说明", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "备注", "fieldType": "TEXT", "required": false},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
// "说明" 命中第一轮
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("含'说明'的字段应被填充: %v", fields[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无关键词时必填文本字段兜底", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "补充信息", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "选择项", "fieldType": "RADIO", "required": true},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("必填文本字段应兜底填充: %v", fields[0])
|
||||
}
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("选择类字段不应猜测填充: %v", fields[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空 fields 与空原因不 panic", func(t *testing.T) {
|
||||
fillXsgzFieldValues(nil, req)
|
||||
fillXsgzFieldValues([]map[string]interface{}{}, req)
|
||||
fillXsgzFieldValues([]map[string]interface{}{{"fieldId": "f1"}}, &ExecuteLeaveApplyRequest{})
|
||||
})
|
||||
}
|
||||
|
||||
// checkXsgzUnfilledRequired:找出必填但无值的字段,用于提交前预检。
|
||||
func TestCheckXsgzUnfilledRequired(t *testing.T) {
|
||||
t.Run("必填有值时不报告", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假原因", "required": true, "value": "感冒"},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
|
||||
t.Fatalf("不应报告缺失,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("必填无值时报告字段名", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "是否离校", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "备注", "required": false},
|
||||
}
|
||||
got := checkXsgzUnfilledRequired(fields)
|
||||
if len(got) != 1 || got[0] != "是否离校" {
|
||||
t.Fatalf("应只报告「是否离校」,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value 为空串也算缺失", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假事由", "required": true, "value": ""},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 1 {
|
||||
t.Fatalf("空串应算缺失,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无 fieldName 时回退 fieldId", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "field_abc", "required": true},
|
||||
}
|
||||
got := checkXsgzUnfilledRequired(fields)
|
||||
if len(got) != 1 || got[0] != "field_abc" {
|
||||
t.Fatalf("应回退到 fieldId,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非必填缺失不报告", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "备注", "required": false},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
|
||||
t.Fatalf("非必填不应报告,实际: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeXsgzFields:按前端 Ie() 语义清洗,避免提交学校端读不到的键。
|
||||
func TestNormalizeXsgzFields(t *testing.T) {
|
||||
t.Run("上传类字段去掉 value", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f1", "componentType": "imageUpload", "value": "脏数据",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if _, ok := f["value"]; ok {
|
||||
t.Fatal("上传类字段不应保留 value(前端走 uploadFiles)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("三种上传类型都处理", func(t *testing.T) {
|
||||
for _, ct := range []string{"imageUpload", "fileUpload", "smoothSignature"} {
|
||||
f := map[string]interface{}{"fieldId": "x", "componentType": ct, "value": "v"}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if _, ok := f["value"]; ok {
|
||||
t.Fatalf("%s 应去掉 value", ct)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多选数组 join 成逗号串", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f2", "componentType": "checkbox",
|
||||
"value": []interface{}{"a", "b", "c"},
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "a,b,c" {
|
||||
t.Fatalf("多选应 join 为 a,b,c,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("数组中的 nil 与空串被跳过", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f3", "componentType": "checkbox",
|
||||
"value": []interface{}{"a", nil, "", "b"},
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "a,b" {
|
||||
t.Fatalf("应跳过 nil 与空串,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("普通字符串字段不受影响", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f4", "componentType": "TEXT", "value": "感冒",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "感冒" {
|
||||
t.Fatalf("普通字段不应被改动,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空输入不 panic", func(t *testing.T) {
|
||||
normalizeXsgzFields(nil)
|
||||
normalizeXsgzFields([]map[string]interface{}{})
|
||||
})
|
||||
}
|
||||
|
||||
// fillXsgzFieldValues 不应把请假原因写进上传类字段。
|
||||
func TestFillSkipsUploadFields(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "请假原因附件", "required": true},
|
||||
}
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if _, ok := fields[0]["value"]; ok {
|
||||
t.Fatalf("上传类字段不应被填充 value,实际 %v", fields[0]["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// 上传类必填字段不应触发「必填未填」拦截。
|
||||
func TestCheckUnfilledSkipsUpload(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "附件", "required": true},
|
||||
{"fieldId": "f2", "componentType": "TEXT", "fieldName": "请假原因", "required": true, "value": "感冒"},
|
||||
}
|
||||
if missing := checkXsgzUnfilledRequired(fields); len(missing) != 0 {
|
||||
t.Fatalf("上传类不应算缺失,实际: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 地区字段:前端 value 是「选中值,详情」拼接串,本平台无此数据,
|
||||
// 不能把请假原因写进去(会产生非法地区串)。
|
||||
func TestRegionsFieldNotFilledWithReason(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "请假原因", "required": false},
|
||||
}
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if v, ok := fields[0]["value"]; ok && v != "" {
|
||||
t.Fatalf("regions 字段不应被填入请假原因,实际 %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 字段残留的非法 value 应被清空。
|
||||
func TestNormalizeRegionsClearsValue(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "r1", "componentType": "regions", "value": "山东省,泰安市,泰山区",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "" {
|
||||
t.Fatalf("regions 的 value 应被清空,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 必填但没有可用数据时,应被预检拦下(而不是发出去被学校端拒绝)。
|
||||
func TestRegionsRequiredTriggersPrecheck(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "籍贯", "required": true},
|
||||
}
|
||||
missing := checkXsgzUnfilledRequired(fields)
|
||||
if len(missing) != 1 || missing[0] != "籍贯" {
|
||||
t.Fatalf("必填 regions 无值应被预检拦下,实际: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// 销假用地址兜底填充时,也不能污染上传类/地区类字段。
|
||||
// 该用例防止「填充」与「normalize 清空」互相打架时留下脏值。
|
||||
func TestCancelFillSkipsNonTextFields(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "销假原因"},
|
||||
{"fieldId": "u1", "componentType": "imageUpload", "fieldName": "销假说明附件"},
|
||||
{"fieldId": "t1", "componentType": "TEXT", "fieldName": "销假说明"},
|
||||
}
|
||||
addr := "山东省泰安市"
|
||||
for _, f := range fields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if strings.Contains(label, "说明") || strings.Contains(label, "原因") {
|
||||
f["value"] = addr
|
||||
}
|
||||
}
|
||||
normalizeXsgzFields(fields)
|
||||
|
||||
// 文本字段应保留地址
|
||||
if fields[2]["value"] != addr {
|
||||
t.Fatalf("文本字段应填入地址,实际 %v", fields[2]["value"])
|
||||
}
|
||||
// 地区字段应被清空(不是地址)
|
||||
if got := fields[0]["value"]; got != "" {
|
||||
t.Fatalf("地区字段应为空,实际 %v", got)
|
||||
}
|
||||
// 上传字段不应有 value
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("上传字段不应有 value,实际 %v", fields[1]["value"])
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -46,6 +47,47 @@ func checkRateLimit(ip string) bool {
|
||||
|
||||
// ---------- 请求结构体 ----------
|
||||
|
||||
// isPrivateIP 判断是否为回环/内网/链路本地等非公网地址
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
|
||||
return true
|
||||
}
|
||||
// IPv4 映射的 IPv6 判断
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4[0] == 0 || v4[0] == 127 || v4[0] == 10 ||
|
||||
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
|
||||
(v4[0] == 192 && v4[1] == 168) ||
|
||||
(v4[0] == 169 && v4[1] == 254)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanClientIP 从可能含多个地址(X-Forwarded-For 链)的值中提取真实公网 IP:
|
||||
// 返回第一个公网(非回环/内网/链路本地等)地址;无公网地址返回空串
|
||||
func cleanClientIP(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(part, "[]"))
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if !isPrivateIP(ip) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AllowedEmailDomains 允许的国内知名邮箱后缀(白名单)
|
||||
var AllowedEmailDomains = []string{
|
||||
"qq.com", "126.com", "163.com", "163.net", "foxmail.com",
|
||||
@@ -91,15 +133,16 @@ type ReportFailedLoginRequest struct {
|
||||
}
|
||||
|
||||
type PendingSchoolRes struct {
|
||||
ID uint `json:"id"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID uint `json:"id"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
RejectReason string `json:"rejectReason"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ---------- 用户端 API ----------
|
||||
@@ -136,7 +179,10 @@ func ReportFailedLogin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 0. 速率限制:同 IP 每天最多 5 次
|
||||
clientIP := c.IP()
|
||||
clientIP := cleanClientIP(c.IP())
|
||||
if clientIP == "" {
|
||||
clientIP = c.IP()
|
||||
}
|
||||
slog.Info("[pending-school] 请求到达",
|
||||
"schoolId", req.SchoolID,
|
||||
"schoolName", req.SchoolName,
|
||||
@@ -151,7 +197,9 @@ func ReportFailedLogin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
// 注意:Truncate(24h) 按 UTC 午夜截断会偏移 8 小时,必须按北京时间当日 0 点
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todayLoginCount int64
|
||||
database.DB.Model(&model.SysUser{}).
|
||||
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
|
||||
@@ -203,6 +251,9 @@ func ReportFailedLogin(c *fiber.Ctx) error {
|
||||
"ip", clientIP,
|
||||
)
|
||||
|
||||
// 异步邮件提醒管理员处理新待适配学校
|
||||
service.NotifyNewPendingSchool(record.SchoolName, record.Username, clientIP, record.Email, record.CreatedAt.Format("2006-01-02 15:04"))
|
||||
|
||||
return utils.Success(c, fiber.Map{"accepted": true})
|
||||
}
|
||||
|
||||
@@ -216,6 +267,10 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||
query := database.DB.Order("created_at DESC")
|
||||
if status == "pending" {
|
||||
query = query.Where("status = ?", "pending")
|
||||
} else if status == "completed" {
|
||||
query = query.Where("status = ?", "completed")
|
||||
} else if status == "rejected" {
|
||||
query = query.Where("status = ?", "rejected")
|
||||
}
|
||||
if err := query.Find(&records).Error; err != nil {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
@@ -223,16 +278,21 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||
|
||||
items := make([]PendingSchoolRes, 0, len(records))
|
||||
for _, r := range records {
|
||||
ip := cleanClientIP(r.IP)
|
||||
if ip == "" {
|
||||
ip = "-"
|
||||
}
|
||||
items = append(items, PendingSchoolRes{
|
||||
ID: r.ID,
|
||||
SchoolID: r.SchoolID,
|
||||
SchoolName: r.SchoolName,
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
Email: r.Email,
|
||||
IP: r.IP,
|
||||
Status: r.Status,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: r.ID,
|
||||
SchoolID: r.SchoolID,
|
||||
SchoolName: r.SchoolName,
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
Email: r.Email,
|
||||
IP: ip,
|
||||
Status: r.Status,
|
||||
RejectReason: r.RejectReason,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -251,6 +311,20 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 404, "记录不存在")
|
||||
}
|
||||
|
||||
// 收集该校全部记录上的联系邮箱(可能多条记录多个邮箱),去重
|
||||
var schoolRecords []model.PendingSchool
|
||||
database.DB.Where("school_id = ?", record.SchoolID).Find(&schoolRecords)
|
||||
emailSet := make(map[string]bool)
|
||||
for _, sr := range schoolRecords {
|
||||
for _, e := range service.SplitEmailsList(sr.Email) {
|
||||
emailSet[e] = true
|
||||
}
|
||||
}
|
||||
var contactEmails []string
|
||||
for e := range emailSet {
|
||||
contactEmails = append(contactEmails, e)
|
||||
}
|
||||
|
||||
// 将该学校所有待处理的记录标记为已完成
|
||||
database.DB.Model(&model.PendingSchool{}).
|
||||
Where("school_id = ? AND status = ?", record.SchoolID, "pending").
|
||||
@@ -258,5 +332,54 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
|
||||
|
||||
slog.Info("学校已标记为适配完成", "schoolId", record.SchoolID, "schoolName", record.SchoolName)
|
||||
|
||||
// 异步邮件通知该学校的全部联系邮箱
|
||||
service.NotifySchoolAdapted(contactEmails, record.SchoolName)
|
||||
|
||||
return utils.Success(c, fiber.Map{"completed": true})
|
||||
}
|
||||
|
||||
// AdminRejectSchool 拒绝某条待适配学校记录(拒绝必须说明理由,理由将邮件通知联系邮箱)
|
||||
func AdminRejectSchool(c *fiber.Ctx) error {
|
||||
recordID, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
return utils.FailParam(c, "无效的记录ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
if req.Reason == "" {
|
||||
return utils.FailParam(c, "拒绝时必须说明理由")
|
||||
}
|
||||
if len([]rune(req.Reason)) > 500 {
|
||||
return utils.FailParam(c, "拒绝理由不能超过500字")
|
||||
}
|
||||
|
||||
var record model.PendingSchool
|
||||
if err := database.DB.First(&record, recordID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "记录不存在")
|
||||
}
|
||||
if record.Status != "pending" {
|
||||
return utils.Fail(c, 400, "该记录已处理,无法拒绝")
|
||||
}
|
||||
|
||||
// 仅拒绝当前这条记录(单条拒绝,不影响同校其他记录)
|
||||
database.DB.Model(&record).Updates(map[string]interface{}{
|
||||
"status": "rejected",
|
||||
"reject_reason": req.Reason,
|
||||
})
|
||||
|
||||
// 邮件只通知该条记录留下的联系邮箱(附拒绝理由)
|
||||
contactEmails := service.SplitEmailsList(record.Email)
|
||||
|
||||
slog.Info("待适配学校记录已被拒绝", "recordId", record.ID, "schoolId", record.SchoolID, "schoolName", record.SchoolName, "reason", req.Reason)
|
||||
|
||||
// 异步邮件通知该条记录的联系邮箱(附拒绝理由)
|
||||
service.NotifySchoolRejected(contactEmails, record.SchoolName, req.Reason)
|
||||
|
||||
return utils.Success(c, fiber.Map{"rejected": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanClientIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"14.31.168.17, 127.0.0.1", "14.31.168.17"},
|
||||
{"127.0.0.1", ""},
|
||||
{"10.0.0.5", ""},
|
||||
{"172.22.0.1", ""},
|
||||
{"192.168.1.1", ""},
|
||||
{"14.31.168.17", "14.31.168.17"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"1.2.3.4, 10.1.1.1, ::1", "1.2.3.4"},
|
||||
{"bad-ip", ""},
|
||||
{"8.8.8.8, 2001:4860:4860::8888", "8.8.8.8"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := cleanClientIP(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("cleanClientIP(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,39 @@ package handler
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// isValidHttpUrl 校验字符串是否为合法的 http(s) 链接(空串合法 = 清空场景)
|
||||
func isValidHttpUrl(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "http" || u.Scheme == "https"
|
||||
}
|
||||
|
||||
// normalizeBoolSetting 将布尔设置值归一化为 "true"/"false"(支持 1/0、on/off 等常见写法)
|
||||
func normalizeBoolSetting(s string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return "true", true
|
||||
case "false", "0", "off", "no", "":
|
||||
return "false", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetSettings 获取所有系统设置
|
||||
func GetSettings(c *fiber.Ctx) error {
|
||||
var configs []model.SystemConfig
|
||||
@@ -30,8 +57,27 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
if _, ok := settings["invitee_reward_per_invite"]; !ok {
|
||||
settings["invitee_reward_per_invite"] = "0.50"
|
||||
}
|
||||
if _, ok := settings["register_reward"]; !ok {
|
||||
settings["register_reward"] = "1.00"
|
||||
if _, ok := settings["vip_price_week"]; !ok {
|
||||
settings["vip_price_week"] = "30.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_month"]; !ok {
|
||||
settings["vip_price_month"] = "100.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductCheckin]; !ok {
|
||||
settings[settingDeductCheckin] = "1.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductLeave]; !ok {
|
||||
settings[settingDeductLeave] = "1.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductReport]; !ok {
|
||||
settings[settingDeductReport] = "1.00"
|
||||
}
|
||||
// 「购买 CDK」入口(个人中心显眼按钮):链接为空 + 默认关闭
|
||||
if _, ok := settings["cdk_buy_url"]; !ok {
|
||||
settings["cdk_buy_url"] = ""
|
||||
}
|
||||
if _, ok := settings["cdk_buy_visible"]; !ok {
|
||||
settings["cdk_buy_visible"] = "false"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
@@ -55,15 +101,58 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
|
||||
// 支持的 key 白名单
|
||||
allowedKeys := map[string]bool{
|
||||
"base_deduction_per_run": true,
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"register_reward": true,
|
||||
"base_deduction_per_run": true,
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"vip_price_week": true,
|
||||
"vip_price_month": true,
|
||||
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
|
||||
settingDeductCheckin: true,
|
||||
settingDeductLeave: true,
|
||||
settingDeductReport: true,
|
||||
// 邮件通知(SMTP)
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
"smtp_user": true,
|
||||
"smtp_password": true,
|
||||
"smtp_from": true,
|
||||
"smtp_from_name": true,
|
||||
"smtp_secure": true,
|
||||
"admin_emails": true,
|
||||
// 「购买 CDK」入口(个人中心显眼按钮的跳转链接与显隐)
|
||||
"cdk_buy_url": true,
|
||||
"cdk_buy_visible": true,
|
||||
}
|
||||
if !allowedKeys[req.Key] {
|
||||
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||
}
|
||||
|
||||
// 扣款额度:必须为非负数字(允许 0 = 不扣费)
|
||||
if req.Key == settingDeductCheckin || req.Key == settingDeductLeave || req.Key == settingDeductReport {
|
||||
v, err := strconv.ParseFloat(req.Value, 64)
|
||||
if err != nil || v < 0 {
|
||||
return utils.FailParam(c, "扣款额度必须为不小于 0 的数字")
|
||||
}
|
||||
}
|
||||
|
||||
// 「购买 CDK」链接:允许清空;非空时必须是 http(s) 链接(前端将以新标签页打开)
|
||||
if req.Key == "cdk_buy_url" {
|
||||
url := strings.TrimSpace(req.Value)
|
||||
if !isValidHttpUrl(url) {
|
||||
return utils.FailParam(c, "购买链接必须以 http:// 或 https:// 开头")
|
||||
}
|
||||
req.Value = url
|
||||
}
|
||||
|
||||
// 「购买 CDK」开关:归一化为 true / false 存储
|
||||
if req.Key == "cdk_buy_visible" {
|
||||
v, ok := normalizeBoolSetting(req.Value)
|
||||
if !ok {
|
||||
return utils.FailParam(c, "展示开关仅支持 true / false")
|
||||
}
|
||||
req.Value = v
|
||||
}
|
||||
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
|
||||
if result.Error != nil {
|
||||
@@ -90,3 +179,18 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"value": req.Value,
|
||||
})
|
||||
}
|
||||
|
||||
// TestMail 发送测试邮件,验证 SMTP 配置是否可用
|
||||
func TestMail(c *fiber.Ctx) error {
|
||||
emails := service.GetAdminEmails()
|
||||
if len(emails) == 0 {
|
||||
return utils.Fail(c, 400, "请先配置管理员邮箱(admin_emails)")
|
||||
}
|
||||
body := `<h2 style="margin:0 0 12px;font-size:16px">SMTP 配置测试</h2>
|
||||
<p style="font-size:14px;color:#555;line-height:1.8">如果您收到本邮件,说明邮件服务配置成功。</p>`
|
||||
if err := service.SendMail(emails, "【小助手】SMTP 测试邮件", service.EmailBody(body)); err != nil {
|
||||
slog.Error("测试邮件发送失败", "to", emails, "error", err)
|
||||
return utils.FailServer(c, "发送失败:"+err.Error())
|
||||
}
|
||||
return utils.Success(c, fiber.Map{"sent": true, "to": emails})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsValidHttpUrl 「购买 CDK」链接校验:仅允许空串(清空)与合法 http(s) 链接。
|
||||
func TestIsValidHttpUrl(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"空串=清空场景合法", "", true},
|
||||
{"https 链接", "https://shop.example.com/cdk", true},
|
||||
{"http 链接", "http://shop.example.com/cdk?from=profile", true},
|
||||
{"带路径与参数", "https://example.com/a/b?x=1&y=2#frag", true},
|
||||
{"端口号", "https://example.com:8443/cdk", true},
|
||||
{"纯域名无协议", "shop.example.com/cdk", false},
|
||||
{"javascript 协议", "javascript:alert(1)", false},
|
||||
{"data 协议", "data:text/html;base64,xxxx", false},
|
||||
{"file 协议", "file:///etc/passwd", false},
|
||||
{"只有协议无主机", "https://", false},
|
||||
{"中文字符串", "不是一个链接", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isValidHttpUrl(c.value); got != c.want {
|
||||
t.Fatalf("isValidHttpUrl(%q) = %v, want %v", c.value, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBoolSetting 布尔设置归一化:常见写法统一为 true/false,其余报错。
|
||||
func TestNormalizeBoolSetting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"true", "true", "true", true},
|
||||
{"TRUE 大写", "TRUE", "true", true},
|
||||
{"1", "1", "true", true},
|
||||
{"on", "on", "true", true},
|
||||
{"带空白", " true ", "true", true},
|
||||
{"false", "false", "false", true},
|
||||
{"0", "0", "false", true},
|
||||
{"off", "off", "false", true},
|
||||
{"空串按关闭", "", "false", true},
|
||||
{"乱值报错", "maybe", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := normalizeBoolSetting(c.value)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("normalizeBoolSetting(%q) = (%q, %v), want (%q, %v)", c.value, got, ok, c.want, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
|
||||
// SmsSendRequest 发送短信验证码请求(需独立的图片验证码)
|
||||
type SmsSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsSend 发送手机短信验证码(对接今日校园)
|
||||
func SmsSend(c *fiber.Ctx) error {
|
||||
var req SmsSendRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
if phone == "" {
|
||||
return utils.FailParam(c, "请输入手机号")
|
||||
}
|
||||
// 独立的图片验证码校验(防脚本刷短信)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
|
||||
ip := utils.ClientIP(c)
|
||||
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": result.Countdown,
|
||||
"tipMsg": result.TipMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// SmsLoginRequest 手机号登录请求(需独立的图片验证码)
|
||||
type SmsLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsLogin 手机号 + 短信验证码登录。
|
||||
// 登录响应自带 tenantId(学校)与 openId(学工号),故无需用户选择学校。
|
||||
// 用户匹配:优先「同校 + 同学工号」复用已有账号,保证与学工号登录指向同一用户。
|
||||
func SmsLogin(c *fiber.Ctx) error {
|
||||
var req SmsLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if phone == "" || code == "" {
|
||||
return utils.FailParam(c, "请输入手机号和验证码")
|
||||
}
|
||||
// 独立的图片验证码校验(登录使用与发码不同的验证码)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
|
||||
if err != nil {
|
||||
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
if loginData.OpenID == "" {
|
||||
return utils.FailServer(c, "登录失败:未获取到学工号")
|
||||
}
|
||||
|
||||
// tenantId 即学校 ID
|
||||
school, err := cpd.GetSchoolDetail(loginData.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.FailServer(c, "获取学校信息失败")
|
||||
}
|
||||
|
||||
// 换取 CAS cookie(含 MOD_AUTH_CAS)
|
||||
deviceID := cpd.CreateDeviceID()
|
||||
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
|
||||
if err != nil {
|
||||
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
|
||||
// 校验是否拿到 MOD_ 认证 cookie
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
|
||||
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("手机号登录用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
}
|
||||
|
||||
if user.Status == "disabled" {
|
||||
reason := user.DisabledReason
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
|
||||
}
|
||||
return c.Status(403).JSON(utils.Response{Code: 403, Message: reason})
|
||||
}
|
||||
|
||||
// 更新 cookie / 登录时间 / 学工号回填
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
}
|
||||
// 手机号登录用户也保存其学工号,便于之后用学工号登录时能匹配到同一账号
|
||||
if user.StudentID != loginData.OpenID {
|
||||
updates["student_id"] = loginData.OpenID
|
||||
}
|
||||
if user.MobilePhone == "" {
|
||||
updates["mobile_phone"] = phone
|
||||
}
|
||||
database.DB.Model(user).Updates(updates)
|
||||
user.StudentID = loginData.OpenID
|
||||
|
||||
// 同步 cpdaily 主页信息(头像/姓名等)
|
||||
if school.WecloudUrl != "" {
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
}
|
||||
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
slog.Info("手机号登录成功", "userId", user.ID, "tenantId", loginData.TenantID,
|
||||
"studentId", loginData.OpenID, "isNew", isNew)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew,
|
||||
})
|
||||
}
|
||||
|
||||
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
|
||||
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
|
||||
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
|
||||
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie string) (*model.SysUser, bool, error) {
|
||||
// 1. 同校 + 同学工号
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 2. 按 username(学校短码 + 学工号)匹配历史数据,命中则回填 school_id/student_id
|
||||
username := buildUsername(school, studentID)
|
||||
err = database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"school_id": school.ID,
|
||||
"student_id": studentID,
|
||||
})
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 3. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
|
||||
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
|
||||
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
|
||||
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
|
||||
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
|
||||
}
|
||||
|
||||
// maskPhone 手机号脱敏(日志用)
|
||||
func maskPhone(p string) string {
|
||||
if len(p) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return p[:3] + "****" + p[len(p)-4:]
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package handler
|
||||
|
||||
// 事务级软删除探针:验证「用户删除日志 → 仅软删除」的四处语义。
|
||||
// 默认跳过;仅在显式设置 PROBE_DB=1 时连接真实数据库执行(需要 backed/config.toml)。
|
||||
// 全过程在单个事务内进行并最终回滚,不写入任何持久数据(AutoMigrate 除外,与 server 启动行为一致)。
|
||||
//
|
||||
// go test ./internal/handler -run TestSoftDeleteProbe -v # 需 PROBE_DB=1
|
||||
//
|
||||
// 覆盖点:
|
||||
// 1. 前台列表(GORM 默认作用域)不展示已删除记录
|
||||
// 2. 任务完成状态判定(Unscoped 查当天成功日志)不受删除影响
|
||||
// 3. 后台统计(已执行累计 / 今日状态 / 今日卡片)计入已删除记录
|
||||
// 4. 后台明细 UNION SQL(adminLogUnionSQL,与 handler 同一常量)含已删除记录并正确标注 deleted/source
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
func TestSoftDeleteProbe(t *testing.T) {
|
||||
if os.Getenv("PROBE_DB") != "1" {
|
||||
t.Skip("跳过:需要 PROBE_DB=1 且可连接数据库")
|
||||
}
|
||||
if err := config.Load("../../config.toml"); err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
database.Init()
|
||||
|
||||
tx := database.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启事务失败: %v", tx.Error)
|
||||
}
|
||||
defer tx.Rollback() // 无论成败一律回滚,探针不留痕
|
||||
|
||||
// ---------- 构造数据(全部在事务内,ID 由自增分配) ----------
|
||||
user := model.SysUser{
|
||||
Username: fmt.Sprintf("sdprobe_%d", time.Now().UnixNano()),
|
||||
Password: "probe",
|
||||
SchoolID: "probe",
|
||||
StudentID: "probe",
|
||||
InviteCode: fmt.Sprintf("p%09d", time.Now().UnixNano()%1000000000), // 避免唯一索引冲突
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建探针用户失败: %v", err)
|
||||
}
|
||||
uid := user.ID
|
||||
|
||||
taskLog := model.CpTaskLog{
|
||||
CpTaskID: 1, UserID: uid, TaskType: "sign",
|
||||
Title: "探针签到", RealTitle: "探针签到", IsSuccess: true,
|
||||
}
|
||||
leaveLog := model.CpLeaveLog{
|
||||
UserID: uid, LeaveID: "probe_leave", Title: "探针请假",
|
||||
SchoolName: "探针学校", Action: model.LeaveActionApply, IsSuccess: true,
|
||||
}
|
||||
if err := tx.Create(&taskLog).Error; err != nil {
|
||||
t.Fatalf("创建任务日志失败: %v", err)
|
||||
}
|
||||
if err := tx.Create(&leaveLog).Error; err != nil {
|
||||
t.Fatalf("创建请假日志失败: %v", err)
|
||||
}
|
||||
|
||||
// 用户端「删除」= GORM 软删除(handler 中即 database.DB.Delete(&log))
|
||||
if err := tx.Delete(&taskLog).Error; err != nil {
|
||||
t.Fatalf("软删除任务日志失败: %v", err)
|
||||
}
|
||||
if err := tx.Delete(&leaveLog).Error; err != nil {
|
||||
t.Fatalf("软删除请假日志失败: %v", err)
|
||||
}
|
||||
|
||||
// 回读确认 deleted_at 已置位(而非物理删除)
|
||||
var rawCount int64
|
||||
if err := tx.Unscoped().Model(&model.CpTaskLog{}).Where("id = ? AND deleted_at IS NOT NULL", taskLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
|
||||
t.Fatalf("任务日志未软删除: count=%d err=%v", rawCount, err)
|
||||
}
|
||||
if err := tx.Unscoped().Model(&model.CpLeaveLog{}).Where("id = ? AND deleted_at IS NOT NULL", leaveLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
|
||||
t.Fatalf("请假日志未软删除: count=%d err=%v", rawCount, err)
|
||||
}
|
||||
|
||||
// ---------- 1. 前台列表不可见(GORM 默认作用域过滤 deleted_at) ----------
|
||||
var frontTask []model.CpTaskLog
|
||||
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontTask)
|
||||
if len(frontTask) != 0 {
|
||||
t.Errorf("[1] 前台任务日志应不可见已删除记录, got %d 条", len(frontTask))
|
||||
}
|
||||
var frontLeave []model.CpLeaveLog
|
||||
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontLeave)
|
||||
if len(frontLeave) != 0 {
|
||||
t.Errorf("[1] 前台请假日志应不可见已删除记录, got %d 条", len(frontLeave))
|
||||
}
|
||||
|
||||
// ---------- 2. 任务完成状态不受删除影响(task.go ListTasks 的跨天重置查询) ----------
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
tx.Unscoped().Model(&model.CpTaskLog{}).
|
||||
Where("user_id = ? AND is_success = 1 AND created_at >= ?", uid, startOfToday).
|
||||
Distinct("cp_task_id").Pluck("cp_task_id", &todaySuccessIDs)
|
||||
if len(todaySuccessIDs) != 1 || todaySuccessIDs[0] != 1 {
|
||||
t.Errorf("[2] 删除当天成功日志后任务完成状态被误重置: IDs=%v", todaySuccessIDs)
|
||||
}
|
||||
|
||||
// ---------- 3. 后台统计计入已删除 ----------
|
||||
var taskSuccess, leaveSuccess int64
|
||||
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&taskSuccess)
|
||||
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&leaveSuccess)
|
||||
if taskSuccess != 1 || leaveSuccess != 1 {
|
||||
t.Errorf("[3] 后台已执行累计应计入已删除: task=%d leave=%d", taskSuccess, leaveSuccess)
|
||||
}
|
||||
var todayTask, todayLeave int64
|
||||
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayTask)
|
||||
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayLeave)
|
||||
if todayTask != 1 || todayLeave != 1 {
|
||||
t.Errorf("[3] 今日统计卡片应计入已删除: task=%d leave=%d", todayTask, todayLeave)
|
||||
}
|
||||
|
||||
// ---------- 4. 后台明细 UNION(同一 adminLogUnionSQL 常量) ----------
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
type combinedRow struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
TaskType string
|
||||
Deleted bool
|
||||
Source int
|
||||
IsSuccess bool
|
||||
}
|
||||
var rows []combinedRow
|
||||
if err := tx.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd).Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("[4] UNION 查询失败: %v", err)
|
||||
}
|
||||
var mine []combinedRow
|
||||
for _, r := range rows {
|
||||
if r.UserID == uid {
|
||||
mine = append(mine, r)
|
||||
}
|
||||
}
|
||||
if len(mine) != 2 {
|
||||
t.Fatalf("[4] UNION 应返回 2 条(任务+请假,均含已删除), got %d", len(mine))
|
||||
}
|
||||
sourceSeen := map[int]bool{}
|
||||
taskTypeSeen := map[string]bool{}
|
||||
for _, r := range mine {
|
||||
if !r.Deleted {
|
||||
t.Errorf("[4] 已删除记录在明细中未标注 deleted: %+v", r)
|
||||
}
|
||||
sourceSeen[r.Source] = true
|
||||
taskTypeSeen[r.TaskType] = true
|
||||
}
|
||||
if !sourceSeen[1] || !sourceSeen[2] {
|
||||
t.Errorf("[4] source 标注缺失: %v", sourceSeen)
|
||||
}
|
||||
if !taskTypeSeen["sign"] || !taskTypeSeen["leave_apply"] {
|
||||
t.Errorf("[4] task_type 映射缺失: %v", taskTypeSeen)
|
||||
}
|
||||
|
||||
// 用户端状态筛选(success=1)走 UNION 包裹分支,确认占位符与包裹 SQL 兼容
|
||||
var successRows []combinedRow
|
||||
tx.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd).Scan(&successRows)
|
||||
found := 0
|
||||
for _, r := range successRows {
|
||||
if r.UserID == uid {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Errorf("[4] success 过滤分支应保留 2 条已删除成功记录, got %d", found)
|
||||
}
|
||||
|
||||
t.Log("软删除探针全部通过:前台隐藏 / 状态不受影响 / 后台计入 / 明细标注")
|
||||
// defer 回滚,探针不落库
|
||||
}
|
||||
+508
-58
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
|
||||
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
|
||||
var tasks []cpd.TodayTaskInfo
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
var fetchErr error
|
||||
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
|
||||
return fetchErr
|
||||
})
|
||||
if err != nil {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, tasks)
|
||||
}
|
||||
|
||||
@@ -106,6 +119,18 @@ func CreateTask(c *fiber.Ctx) error {
|
||||
return utils.FailParam(c, "参数不完整")
|
||||
}
|
||||
|
||||
// 同一用户禁止创建相同任务名
|
||||
var dupCount int64
|
||||
if err := database.DB.Model(&model.CpTask{}).
|
||||
Where("user_id = ? AND title = ?", userID, req.Title).
|
||||
Count(&dupCount).Error; err != nil {
|
||||
slog.Error("查重任务失败", "error", err)
|
||||
return utils.FailServer(c, "查询任务失败")
|
||||
}
|
||||
if dupCount > 0 {
|
||||
return utils.Fail(c, 400, "任务名称已存在,请勿重复创建")
|
||||
}
|
||||
|
||||
task := model.CpTask{
|
||||
UserID: userID,
|
||||
TaskType: req.TaskType,
|
||||
@@ -136,6 +161,49 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 返校登记无形表单:直接返回空表单,前端保存空 formData 即可
|
||||
if cpd.TaskType(task.TaskType) == cpd.TaskTypeReturnRegister {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"formItems": []model.CpTaskFormItem{},
|
||||
"realTitle": task.RealTitle,
|
||||
"isPhoto": false,
|
||||
})
|
||||
}
|
||||
|
||||
// 工作日志(教师端):表单来自 worklog/detail,字段结构与签到不同,单独转换
|
||||
if cpd.TaskType(task.TaskType) == cpd.TaskTypeWorklog {
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
_, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务标识无效")
|
||||
}
|
||||
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取工作日志表单失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, fiber.Map{
|
||||
"formItems": cpd.WorklogFieldsToFormItems(fields),
|
||||
"realTitle": task.RealTitle,
|
||||
"isPhoto": false,
|
||||
})
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
@@ -145,20 +213,22 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 从 cpdaily 拉取任务详情
|
||||
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err := cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
if err != nil {
|
||||
// cookie 过期时自动尝试重新登录
|
||||
if isCookieExpired(err) {
|
||||
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
|
||||
@@ -182,6 +252,30 @@ type SaveTaskFormRequest struct {
|
||||
FormData []model.CpTaskFormItem `json:"formData"`
|
||||
}
|
||||
|
||||
// missingRequiredFormFields 返回表单中「必填但内容为空」的字段标题列表。
|
||||
// 学校表单模板中必填项为空时提交必然失败(如签到地点未配置 → 服务端报"签到地点纬度不能为空"),
|
||||
// 因此在执行前拦截并给出明确提示,而不是等提交失败后只报笼统错误。
|
||||
func missingRequiredFormFields(formData string) []string {
|
||||
var items []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(formData), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
for _, it := range items {
|
||||
if !it.Required || !it.Show {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(it.Content) == "" {
|
||||
name := it.Title
|
||||
if name == "" {
|
||||
name = it.ColName
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func SaveTaskForm(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -206,10 +300,20 @@ func SaveTaskForm(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// TaskWithMatch 任务列表附带匹配状态
|
||||
type TaskWithMatch struct {
|
||||
model.CpTask
|
||||
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
|
||||
// TaskListItem 任务列表的轻量响应项。
|
||||
// 关键:**不包含 formData**。formData 可能是 longtext(历史数据里存过 base64 图片,
|
||||
// 单条曾达 2.9MB),把它放进列表接口会让首页每次多传数 MB。
|
||||
// 列表渲染只需要下面这些字段;表单内容另走 /task/form-data/:id 获取。
|
||||
type TaskListItem struct {
|
||||
ID uint `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
Title string `json:"title"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
TaskStatus string `json:"taskStatus"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
MatchStatus string `json:"matchStatus"`
|
||||
MissingFields []string `json:"missingFields"` // 必填但为空的字段标题(服务端预计算,前端无需解析 formData)
|
||||
}
|
||||
|
||||
// globMatch 简单通配符匹配(仅支持 *)
|
||||
@@ -253,9 +357,36 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
var tasks []model.CpTask
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
|
||||
|
||||
// 跨天惰性重置:签到/查寝均为每日任务,"已完成(success)"只应代表当天成功。
|
||||
// 昨日及更早的 success 若继续保留,任务卡会一直显示绿色对勾、隐藏执行按钮,
|
||||
// 误导用户"今天已执行"且无法再次打卡。以"当天是否存在成功日志"为准判定并重置回 pending。
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
// Unscoped:用户在客户端"删除"日志只是软删除,不代表当天没执行过;
|
||||
// 若漏加会因当天成功日志被删而把任务重置回 pending,导致重复打卡
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).
|
||||
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
|
||||
Distinct("cp_task_id").
|
||||
Pluck("cp_task_id", &todaySuccessIDs)
|
||||
todaySuccessSet := make(map[uint]bool, len(todaySuccessIDs))
|
||||
for _, tid := range todaySuccessIDs {
|
||||
todaySuccessSet[tid] = true
|
||||
}
|
||||
for i := range tasks {
|
||||
if tasks[i].TaskStatus == "success" && !todaySuccessSet[tasks[i].ID] {
|
||||
tasks[i].TaskStatus = "pending"
|
||||
tasks[i].ErrorMessage = ""
|
||||
database.DB.Model(&model.CpTask{}).Where("id = ?", tasks[i].ID).
|
||||
Updates(map[string]interface{}{"task_status": "pending", "error_message": ""})
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取今日任务进行匹配
|
||||
var user model.SysUser
|
||||
var todayTasks []cpd.TodayTaskInfo
|
||||
// 返校登记待办(taskWid:stuWid 集合),用于匹配 return_register 任务
|
||||
var returnRegisterPending map[string]bool
|
||||
fetchOk := false
|
||||
|
||||
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
|
||||
@@ -286,12 +417,46 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
fetchOk = true
|
||||
}
|
||||
}
|
||||
|
||||
// 返校登记待办(供 return_register 任务匹配;失败不影响签到类)
|
||||
returnRegisterPending = map[string]bool{}
|
||||
if rr, rerr := cpd.FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
returnRegisterPending[t.SignWid] = true
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "userId", userID, "error", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回结果
|
||||
result := make([]TaskWithMatch, len(tasks))
|
||||
// 构造返回结果(轻量 DTO:不含 formData)
|
||||
result := make([]TaskListItem, len(tasks))
|
||||
for i, task := range tasks {
|
||||
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
|
||||
item := TaskListItem{
|
||||
ID: task.ID,
|
||||
TaskType: task.TaskType,
|
||||
Title: task.Title,
|
||||
RealTitle: task.RealTitle,
|
||||
TaskStatus: task.TaskStatus,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
MatchStatus: "check_failed",
|
||||
MissingFields: missingRequiredFormFields(task.FormData),
|
||||
}
|
||||
// 返校登记:按待办集合判断(在待办中=未登记;不在=已登记/已过期)
|
||||
if task.TaskType == string(cpd.TaskTypeReturnRegister) {
|
||||
if returnRegisterPending != nil {
|
||||
item.MatchStatus = "no_match"
|
||||
if returnRegisterPending[task.SignWid] {
|
||||
item.MatchStatus = "matched_unsigned"
|
||||
} else {
|
||||
item.MatchStatus = "matched_signed"
|
||||
}
|
||||
}
|
||||
item.MissingFields = nil // 返校登记无形表单
|
||||
result[i] = item
|
||||
continue
|
||||
}
|
||||
if fetchOk {
|
||||
item.MatchStatus = "no_match"
|
||||
// 用 title 做 glob 匹配(支持 * 通配符)
|
||||
@@ -315,6 +480,25 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
return utils.Success(c, result)
|
||||
}
|
||||
|
||||
// GetTaskSavedForm 返回该任务**已保存**的表单内容(formData),供编辑弹窗预填。
|
||||
// 与 GetTaskForm 的区别:后者是从今日校园重新拉取表单模板(会丢失用户已填值)。
|
||||
// 单独成接口,是为了让列表接口(/task/list)不必携带大字段 formData。
|
||||
func GetTaskSavedForm(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var task model.CpTask
|
||||
if err := database.DB.Select("id", "form_data").
|
||||
Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 404, "任务不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"formData": task.FormData})
|
||||
}
|
||||
|
||||
// DeleteTask 删除任务
|
||||
func DeleteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
@@ -328,7 +512,8 @@ func DeleteTask(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// ExecuteTask 同步执行签到(SSE 流式返回状态)
|
||||
// ExecuteTask 执行任务(按 taskType 分流)。
|
||||
// sign / sleep_check → 签到提交流程;return_register → 返校登记(确认到达)。
|
||||
func ExecuteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -341,17 +526,72 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
switch cpd.TaskType(task.TaskType) {
|
||||
case cpd.TaskTypeReturnRegister:
|
||||
return executeReturnRegisterTask(c, &task)
|
||||
case cpd.TaskTypeWorklog:
|
||||
return executeWorklogTask(c, &task)
|
||||
default:
|
||||
return executeSignTask(c)
|
||||
}
|
||||
}
|
||||
|
||||
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
|
||||
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
|
||||
// 兼容字符串 "是"/"1"/"true" 与数值 1。
|
||||
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
|
||||
v, ok := submitForm["isMalposition"]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case int:
|
||||
return t == 1
|
||||
case int64:
|
||||
return t == 1
|
||||
case float64:
|
||||
return t == 1
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
return s == "是" || s == "1" || s == "true" || s == "True"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
|
||||
func executeSignTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var task model.CpTask
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 404, "任务不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
if task.FormData == "" {
|
||||
return utils.Fail(c, 400, "请先配置表单数据")
|
||||
}
|
||||
if missing := missingRequiredFormFields(task.FormData); len(missing) > 0 {
|
||||
return utils.Fail(c, 400, "签到信息未配置完整,缺少:"+strings.Join(missing, "、")+",请编辑任务补全后再执行")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -381,9 +621,15 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
// 检查可用次数
|
||||
if user.SignCount <= 0 {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 本次打卡扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getCheckinDeduct()
|
||||
|
||||
// 检查可用次数(VIP 免检查;0 额度时恒放行)
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -413,46 +659,79 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 2. 获取表单详情
|
||||
// 2. 获取表单详情(按任务名从当天列表实时匹配实例,wid 每次执行时自动获取)
|
||||
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
|
||||
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err := cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
// 续期成功但仍过期:不能让原始 ErrCookieExpired 透传到用户面前
|
||||
// (否则界面显示 cpdaily_cookie_expired 这种内部错误码)。
|
||||
// 说明自动续期拿到的凭证依然无效,等同于需要用户重新登录。
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Warn("自动续期后仍提示登录态失效,判定需重新登录", "taskId", id, "error", err)
|
||||
err = wrapNeedReLogin(err)
|
||||
}
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取签到表单失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
// 统一兜底:避免 cpdaily_cookie_expired 等内部错误码直接展示给用户
|
||||
errMsg, needLoginNow := userFacingError("获取签到表单失败: ", err)
|
||||
slog.Error("获取表单失败", "taskId", id, "error", err)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
|
||||
if task.RealTitle == "" {
|
||||
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
|
||||
for _, t := range todayTasks {
|
||||
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
|
||||
task.RealTitle = t.TaskName
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
break
|
||||
}
|
||||
}
|
||||
// 详情来自当天列表匹配,RealTitle 即列表中的真实任务名
|
||||
if task.RealTitle == "" && detail.RealTitle != "" && detail.RealTitle != task.Title {
|
||||
task.RealTitle = detail.RealTitle
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 反序列化用户配置的表单
|
||||
// 3. 已签到且不允许更新 → 直接拦截(逻辑取自签到 H5:
|
||||
// if (signed && !isAllowUpdate) toast("更新签到开关已关闭");
|
||||
// updateLimit 且有 leftNum 才算剩余更新次数)
|
||||
if msg, blocked := cpd.SignUpdateBlocked(detail.Rows); blocked {
|
||||
slog.Warn("已签到且不允许更新,拒绝执行", "taskId", id, "userId", userID, "reason", msg)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": msg,
|
||||
})
|
||||
saveLog(false, msg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": msg, "alreadySigned": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3.1 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
|
||||
// (查寝走 singleTaskBeginTime/EndTime 完整时间)。不在时间段内直接返回明确提示,
|
||||
// 不再把注定被学校端拒绝的提交发出去,也避免用户白扣次数。
|
||||
if wErr := cpd.CheckSignWindow(detail.Rows, time.Now()); wErr != nil {
|
||||
errMsg := wErr.Error()
|
||||
slog.Warn("不在打卡时间段内,拒绝执行", "taskId", id, "userId", userID, "error", wErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": errMsg, "outsideWindow": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 反序列化用户配置的表单
|
||||
var formItems []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
|
||||
errMsg := "表单数据解析失败"
|
||||
@@ -468,47 +747,176 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
// 4. 填充表单并提交
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
|
||||
|
||||
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
submitForm, formErr := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
if formErr != nil {
|
||||
errMsg := "生成签到表单失败: " + formErr.Error()
|
||||
slog.Error("生成签到表单失败", "taskId", id, "error", formErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
|
||||
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
|
||||
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
|
||||
if !isOutOfRangeSign(submitForm) {
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
}
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
previewUrl, upErr := cpd.UploadDataURIImage(cookieMap, host, cpd.TaskType(task.TaskType), photo)
|
||||
if upErr != nil {
|
||||
errMsg := "照片上传失败: " + upErr.Error()
|
||||
slog.Error("照片上传失败", "taskId", id, "error", upErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
submitForm["signPhotoUrl"] = previewUrl
|
||||
} else if photo, ok := submitForm["signPhotoUrl"].(string); ok && photo != "" {
|
||||
// 照片是已上传的 OBS 预览 URL:其签名由云端签发、有效期很短,而任务是持久化的,
|
||||
// 隔日执行时原签名多已过期,直接提交会被学校判为签到失败(如兰州交大 2210010000)。
|
||||
// 用 URL 里稳定的对象 key 重签一个未过期的 URL(同一张照片,无需用户重传)。
|
||||
// 重签失败不阻断:回退使用原 URL,由学校端判定,避免因预览接口偶发故障误杀签到。
|
||||
if refreshed, rerr := cpd.RefreshPreviewURL(cookieMap, host, cpd.TaskType(task.TaskType), photo); rerr == nil {
|
||||
submitForm["signPhotoUrl"] = refreshed
|
||||
} else {
|
||||
slog.Warn("签到照片重签失败,回退原URL", "taskId", id, "error", rerr)
|
||||
}
|
||||
}
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
// 同上一处:签到失败也要避免内部错误码外泄
|
||||
errMsg, needLoginNow := userFacingError(
|
||||
fmt.Sprintf("签到失败(%s): ", taskTitle(task)), err)
|
||||
slog.Error("签到失败", "taskId", id, "error", err)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
|
||||
return
|
||||
}
|
||||
|
||||
// 4.5 人脸识别复核(重要):今日校园对开启人脸识别的任务,submitSign 返回 code=0
|
||||
// 仅表示「受理成功」,人脸核验在服务端异步进行,可能事后被系统自动驳回
|
||||
// (如 FRS.0036)。仅凭 code=0 判定成功会导致「假成功」:日志记成功、照常扣费,
|
||||
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
|
||||
if cpd.NeedsFaceIdentify(detail.Rows) {
|
||||
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
|
||||
// 轮询等待:识别在服务端排队,可能返回「识别中,预计等待N位」。
|
||||
// 这种情况必须继续等待而不是判失败,因此:
|
||||
// - 前 4 轮间隔 0/1/2/3 秒,快速拿到常见情形(多数几秒内出结果);
|
||||
// - 之后转为每 3 秒一轮,最多再等 faceQueueMaxWait,
|
||||
// 覆盖排队靠后的情况;
|
||||
// - 期间若出现明确失败文案则立即判失败,出结果(signStatus=4)则成功。
|
||||
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
|
||||
const faceQueueMaxWait = 90 * time.Second
|
||||
deadline := time.Now().Add(faceQueueMaxWait)
|
||||
pendingHint := ""
|
||||
rejected := false
|
||||
var rejectMsg string
|
||||
settledOK := false
|
||||
for i := 0; ; i++ {
|
||||
var d time.Duration
|
||||
if i < len(delays) {
|
||||
d = delays[i]
|
||||
} else if time.Now().Before(deadline) {
|
||||
d = 3 * time.Second
|
||||
} else {
|
||||
break // 等够了,走「未出结果按成功」兜底
|
||||
}
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
res, cerr := cpd.CheckFaceIdentifyResult(cookieMap, host, cpd.TaskType(task.TaskType),
|
||||
detail.SignWid, detail.SignInstanceWid, detail.RealTitle)
|
||||
if cerr != nil {
|
||||
slog.Warn("人脸识别结果复核失败,稍后重试", "taskId", id, "error", cerr)
|
||||
continue
|
||||
}
|
||||
if res.Rejected {
|
||||
rejected = true
|
||||
rejectMsg = res.Message
|
||||
break
|
||||
}
|
||||
if res.Settled {
|
||||
settledOK = true
|
||||
break
|
||||
}
|
||||
// 尚未出结果(含「识别中,预计等待N位」排队提示):继续等待。
|
||||
// 把最新排队提示透传给前端,让用户看到进度而不是干等。
|
||||
if res.Message != "" && res.Message != pendingHint {
|
||||
pendingHint = res.Message
|
||||
sendEvent("verifying", map[string]interface{}{
|
||||
"status": "verifying", "message": pendingHint, "queued": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if rejected {
|
||||
if rejectMsg == "" {
|
||||
rejectMsg = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
|
||||
}
|
||||
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), rejectMsg)
|
||||
slog.Error("人脸识别被系统驳回,判定签到失败", "taskId", id, "userId", userID, "message", rejectMsg)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "faceRejected": true})
|
||||
return // 不扣费
|
||||
}
|
||||
if !settledOK {
|
||||
// 三轮后仍无明确结果:不阻断,按成功处理(避免误杀正常签到)
|
||||
slog.Warn("人脸识别复核超时未出结果,按提交成功处理", "taskId", id, "userId", userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 成功
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "success",
|
||||
})
|
||||
saveLog(true, "", taskTitle(task))
|
||||
|
||||
// 扣减可用次数(原子操作,防止并发超卖)
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
})
|
||||
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "签到成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "签到成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
|
||||
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -557,7 +965,7 @@ var _ = time.Now
|
||||
|
||||
// UpdateTaskRequest 更新任务请求
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Title *string `json:"title"`
|
||||
FormData *[]model.CpTaskFormItem `json:"formData"`
|
||||
}
|
||||
|
||||
@@ -619,19 +1027,40 @@ func GetUploadPolicy(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 请假类图片:新套学校走 leave-new OBS 协议(COS 风格),旧套保持原路径
|
||||
if req.TaskType == string(cpd.TaskTypeLeave) && schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
policy, err := cpd.GetLeaveNewUploadPolicy(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
policy, err = cpd.GetLeaveNewUploadPolicy(cookieMap, host)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取上传策略失败(新套)", "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, policy)
|
||||
}
|
||||
|
||||
policy, err := cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取上传策略cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取上传策略失败", "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
|
||||
@@ -663,19 +1092,40 @@ func GetPreviewUrl(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 请假类图片预览:新套学校走 leave-new OBS 协议(参数 obsKey)
|
||||
if req.TaskType == string(cpd.TaskTypeLeave) && schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
previewUrl, err := cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
previewUrl, err = cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取预览 URL 失败(新套)", "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, fiber.Map{"previewUrl": previewUrl})
|
||||
}
|
||||
|
||||
previewUrl, err := cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取预览URL cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取预览 URL 失败", "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
|
||||
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
|
||||
func TestIsOutOfRangeSign(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"缺字段默认范围内", map[string]interface{}{}, false},
|
||||
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
|
||||
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
|
||||
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
|
||||
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
|
||||
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
|
||||
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
|
||||
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
|
||||
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
|
||||
{"nil", map[string]interface{}{"isMalposition": nil}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isOutOfRangeSign(c.form); got != c.want {
|
||||
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// executeReturnRegisterTask 执行返校登记任务(SSE 流式返回,与签到执行体验一致)。
|
||||
// 返校登记无形表单:直接取学校配置的校区坐标,对 signWid 中的 (taskWid, stuWid) 提交确认到达。
|
||||
// 事件流:connecting → submitting → success / task_error
|
||||
func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
// 解析任务标识 "taskWid:stuWid"(在打开 SSE 流前完成,参数错误可直接返回 JSON)
|
||||
taskWid, stuWid, perr := cpd.ParseLeaveReturnSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务配置无效,请删除后重新创建")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpTaskLog{
|
||||
CpTaskID: task.ID,
|
||||
UserID: userID,
|
||||
TaskType: task.TaskType,
|
||||
Title: task.Title,
|
||||
RealTitle: task.RealTitle,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
deduct := getCheckinDeduct()
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("返校登记次数不足,拒绝执行", "userId", userID, "taskId", task.ID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户同一任务
|
||||
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("返校登记触发限流", "userId", userID, "taskId", task.ID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
"error_message": "",
|
||||
})
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 取学校配置的校区坐标(范围内登记)
|
||||
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取返校位置配置失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if len(places) == 0 {
|
||||
errMsg := "学校未配置返校确认位置,无法自动登记"
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
place := places[0]
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交返校登记..."})
|
||||
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "返校登记失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "success"})
|
||||
saveLog(true, "")
|
||||
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "返校登记成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "返校登记成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "返校登记成功!(VIP 免扣次数)"})
|
||||
}
|
||||
|
||||
slog.Info("返校登记成功", "taskId", task.ID, "userId", userID, "taskWid", taskWid, "stuWid", stuWid)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 教师端「工作日志」执行(SSE)。
|
||||
// 流程:取表单 → 文本项回填 → 定位签到项走 submitSign → update(operationType=1) 提交。
|
||||
// 事件流与其他任务一致:connecting → submitting → success / task_error。
|
||||
//
|
||||
// 注意:本流程依据公开逆向实现(spec://worklog_api.md)编写,尚未经真实教师会话端到端验证。
|
||||
func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if strings.TrimSpace(user.Cookie) == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
formWid, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务标识无效")
|
||||
}
|
||||
|
||||
// 保存的表单值(colName=字段 wid → content),用于回填
|
||||
saved := map[string]string{}
|
||||
for _, it := range parseSavedFormItems(task.FormData) {
|
||||
saved[it.ColName] = it.Content
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpTaskLog{
|
||||
UserID: userID, CpTaskID: task.ID, TaskType: string(cpd.TaskTypeWorklog),
|
||||
Title: task.Title, RealTitle: task.RealTitle, IsSuccess: isSuccess, ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
msg := "获取工作日志表单失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
msg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交工作日志..."})
|
||||
|
||||
// 逐字段回填:普通字段赋值;签到项(signCondition=true)走 submitSign。
|
||||
// 签到项的坐标由用户在编辑时选择校区写入该字段 content("lat,lng",取该校区的规定经纬度)。
|
||||
for i := range fields {
|
||||
f := &fields[i]
|
||||
if v, ok := saved[strconv.FormatInt(f.Wid, 10)]; ok && strings.TrimSpace(v) != "" {
|
||||
f.Value = v
|
||||
}
|
||||
if !f.SignCondition {
|
||||
continue
|
||||
}
|
||||
lat, lon := parseWorklogLatLon(f.Value)
|
||||
if lon == "" || lat == "" {
|
||||
msg := "工作日志「" + f.Title + "」需要选择签到校区,请在编辑任务时选择后重试"
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
|
||||
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !cpd.WorklogWithinRange(*f, lat, lon) {
|
||||
msg := "工作日志「" + f.Title + "」当前定位不在允许的签到范围内,请到指定校区后重新获取定位"
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, "", lon, lat); serr != nil {
|
||||
msg := "签到失败: " + serr.Error()
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := cpd.SubmitWorklog(cookieMap, host, formWid, wid, fields); err != nil {
|
||||
msg := "提交工作日志失败: " + err.Error()
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
database.DB.Model(&model.CpTask{}).Where("id = ?", task.ID).
|
||||
Updates(map[string]interface{}{"task_status": "success", "error_message": ""})
|
||||
slog.Info("工作日志提交成功", "userId", userID, "taskId", task.ID)
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "工作日志提交成功!"})
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSavedFormItems 解析任务已保存表单(best-effort,解析失败返回空)
|
||||
func parseSavedFormItems(raw string) []model.CpTaskFormItem {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var items []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// parseWorklogLatLon 解析签到字段 content 中的 "lat,lng"。
|
||||
// 前端由用户选择校区后写入该校区的规定经纬度(lat,lng),学校按点位半径校验。
|
||||
func parseWorklogLatLon(v string) (lat, lon string) {
|
||||
parts := strings.SplitN(strings.TrimSpace(v), ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"fmt"
|
||||
|
||||
@@ -43,6 +44,8 @@ type AdminTicketListItem struct {
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type TicketDetail struct {
|
||||
@@ -68,18 +71,29 @@ type TicketMessageRes struct {
|
||||
type TicketNotificationRes struct {
|
||||
ID uint `json:"id"`
|
||||
TicketID uint `json:"ticketId"`
|
||||
Kind string `json:"kind"` // reply(管理员回复) / status(状态变更)
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
IsRead bool `json:"isRead"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// 关联工单当前状态(pending/processing/resolved/closed);用于前端排序:
|
||||
// 未关闭工单的回复通知排在前面,已关闭的排在后面
|
||||
TicketStatus string `json:"ticketStatus"`
|
||||
}
|
||||
|
||||
// ---------- 通知辅助函数 ----------
|
||||
|
||||
func createTicketNotification(ticketID, userID uint, title, content string) {
|
||||
// 通知类型:reply=管理员回复 / status=状态变更
|
||||
const (
|
||||
ticketNotifKindReply = "reply"
|
||||
ticketNotifKindStatus = "status"
|
||||
)
|
||||
|
||||
func createTicketNotification(ticketID, userID uint, kind, title, content string) {
|
||||
notif := model.TicketNotification{
|
||||
UserID: userID,
|
||||
TicketID: ticketID,
|
||||
Kind: kind,
|
||||
Title: title,
|
||||
Content: content,
|
||||
}
|
||||
@@ -117,6 +131,12 @@ func CreateTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "创建工单失败")
|
||||
}
|
||||
|
||||
// 异步邮件提醒管理员处理新工单
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id, username, real_name, school_name").First(&u, userID).Error; err == nil {
|
||||
service.NotifyNewTicket(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, ticket.Content, ticket.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": ticket.ID,
|
||||
})
|
||||
@@ -235,6 +255,14 @@ func AddTicketMessage(c *fiber.Ctx) error {
|
||||
database.DB.Model(&ticket).Update("status", "pending")
|
||||
}
|
||||
|
||||
// 异步邮件提醒管理员:工单对话有新回复
|
||||
go func() {
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("username, real_name, school_name").First(&u, userID).Error; err == nil {
|
||||
service.NotifyTicketUserReply(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, msg.Content, msg.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}()
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": msg.ID,
|
||||
})
|
||||
@@ -265,7 +293,11 @@ func CloseTicket(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "closed"})
|
||||
}
|
||||
|
||||
// DeleteTicket 用户删除工单
|
||||
// DeleteTicket 用户删除工单(软删除 + 自动关闭)。
|
||||
// 1. 标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
|
||||
// 因此不级联删除消息/通知,避免用户删除后抹掉处理痕迹;
|
||||
// 2. 同时将状态置为 closed(用户删除视为自动关闭),此后用户无法再回复,
|
||||
// 管理端也不再将其计入「未关闭」待办。
|
||||
func DeleteTicket(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
ticketID, err := c.ParamsInt("id")
|
||||
@@ -281,12 +313,16 @@ func DeleteTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 级联删除:消息、通知
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
|
||||
database.DB.Delete(&ticket)
|
||||
// 先置为关闭,再软删除(两条 UPDATE;软删除后 GORM 默认作用域将查不到该行)
|
||||
if ticket.Status != "closed" {
|
||||
database.DB.Model(&ticket).Update("status", "closed")
|
||||
}
|
||||
// 软删除(GORM 自动转为 UPDATE deleted_at);消息与通知保留,供管理端追溯
|
||||
if err := database.DB.Delete(&ticket).Error; err != nil {
|
||||
return utils.FailServer(c, "删除失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"deleted": true})
|
||||
return utils.Success(c, fiber.Map{"deleted": true, "status": "closed"})
|
||||
}
|
||||
|
||||
// ---------- 工单通知 API ----------
|
||||
@@ -298,15 +334,31 @@ func ListTicketNotifications(c *fiber.Ctx) error {
|
||||
var notifs []model.TicketNotification
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(¬ifs)
|
||||
|
||||
// 批量取关联工单状态(用于前端把「未关闭工单」的通知排在前面)
|
||||
ticketIDs := make([]uint, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
ticketIDs = append(ticketIDs, n.TicketID)
|
||||
}
|
||||
statusByTicket := map[uint]string{}
|
||||
if len(ticketIDs) > 0 {
|
||||
var tickets []model.Ticket
|
||||
database.DB.Unscoped().Select("id", "status").Where("id IN ?", ticketIDs).Find(&tickets)
|
||||
for _, t := range tickets {
|
||||
statusByTicket[t.ID] = t.Status
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]TicketNotificationRes, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
items = append(items, TicketNotificationRes{
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Kind: n.Kind,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
TicketStatus: statusByTicket[n.TicketID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,11 +391,12 @@ func MarkTicketNotificationRead(c *fiber.Ctx) error {
|
||||
// ---------- 管理端 API ----------
|
||||
|
||||
// AdminListTickets 管理端工单列表
|
||||
// 使用 Unscoped 包含用户已软删除的工单,便于管理员追溯(列表中标记「用户已删除」)。
|
||||
func AdminListTickets(c *fiber.Ctx) error {
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
query := database.DB.Table("tickets").
|
||||
query := database.DB.Unscoped().Table("tickets").
|
||||
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
|
||||
Order("tickets.created_at DESC")
|
||||
@@ -382,6 +435,7 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,7 +450,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:管理员可查看用户已软删除的工单(含完整对话)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -434,6 +489,7 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
"schoolName": user.SchoolID,
|
||||
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
"deleted": ticket.DeletedAt.Valid,
|
||||
"messages": msgRes,
|
||||
})
|
||||
}
|
||||
@@ -455,7 +511,8 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单继续回复(工单内容仍保留可追溯)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -484,7 +541,7 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
// 发送通知给用户
|
||||
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
|
||||
content := truncateString(req.Content, 100)
|
||||
createTicketNotification(ticket.ID, ticket.UserID, title, content)
|
||||
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindReply, title, content)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": msg.ID,
|
||||
@@ -514,7 +571,8 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单更新状态
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -527,12 +585,12 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
statusLabels := map[string]string{
|
||||
"pending": "待处理",
|
||||
"processing": "处理中",
|
||||
"resolved": "已解决",
|
||||
"closed": "已关闭",
|
||||
"resolved": "已完成",
|
||||
"closed": "关闭",
|
||||
}
|
||||
title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID)
|
||||
content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status])
|
||||
createTicketNotification(ticket.ID, ticket.UserID, title, content)
|
||||
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindStatus, title, content)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"status": req.Status,
|
||||
|
||||
@@ -38,7 +38,7 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||
|
||||
var announcements []model.Announcement
|
||||
database.DB.Where(
|
||||
"(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?",
|
||||
"is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at DESC").Find(&announcements)
|
||||
|
||||
@@ -66,6 +66,40 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// PinnedAnnouncementItem 置顶公告项(打卡页顶部横向滚动展示)
|
||||
type PinnedAnnouncementItem struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListPinnedAnnouncements 获取当前用户可见的置顶公告(打卡页顶部横向滚动)
|
||||
func ListPinnedAnnouncements(c *fiber.Ctx) error {
|
||||
userID, userType, err := getCurrentUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "未认证")
|
||||
}
|
||||
|
||||
var announcements []model.Announcement
|
||||
database.DB.Where(
|
||||
"is_pinned = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at DESC").Find(&announcements)
|
||||
|
||||
items := make([]PinnedAnnouncementItem, 0, len(announcements))
|
||||
for _, a := range announcements {
|
||||
items = append(items, PinnedAnnouncementItem{
|
||||
ID: a.ID,
|
||||
Title: a.Title,
|
||||
Content: a.Content,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取未读公告数
|
||||
func GetUnreadCount(c *fiber.Ctx) error {
|
||||
userID, userType, err := getCurrentUser(c)
|
||||
@@ -75,7 +109,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
|
||||
|
||||
var totalCount int64
|
||||
database.DB.Model(&model.Announcement{}).
|
||||
Where("(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", userType, userID).
|
||||
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Count(&totalCount)
|
||||
|
||||
var readCount int64
|
||||
@@ -118,10 +152,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
||||
return utils.FailAuth(c, "未认证")
|
||||
}
|
||||
|
||||
// 1. 总公告数(包含指定给当前用户的)
|
||||
// 1. 总公告数(包含指定给当前用户的;不含置顶公告)
|
||||
var totalCount int64
|
||||
database.DB.Model(&model.Announcement{}).
|
||||
Where("(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", userType, userID).
|
||||
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Count(&totalCount)
|
||||
|
||||
// 2. 已读数
|
||||
@@ -138,7 +172,7 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
||||
// 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读)
|
||||
var forceAnnouncements []model.Announcement
|
||||
database.DB.Where(
|
||||
"force_popup = 1 AND ((target_type = ? AND target_user_id IS NULL) OR target_user_id = ?)",
|
||||
"force_popup = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at ASC").Find(&forceAnnouncements)
|
||||
|
||||
@@ -185,10 +219,16 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 404, "公告不存在")
|
||||
}
|
||||
|
||||
// 验证公告目标类型匹配或指定给当前用户
|
||||
if announcement.TargetType != userType && (announcement.TargetUserID == nil || *announcement.TargetUserID != userID) {
|
||||
// 置顶公告不进入用户公告列表,不可被标记已读
|
||||
if announcement.IsPinned {
|
||||
return utils.Fail(c, 400, "置顶公告无需标记已读")
|
||||
}
|
||||
|
||||
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
|
||||
if announcement.TargetType != userType {
|
||||
return utils.Fail(c, 403, "无权操作此公告")
|
||||
}
|
||||
// 指定给其他用户的公告无权操作
|
||||
if announcement.TargetUserID != nil && *announcement.TargetUserID != userID {
|
||||
return utils.Fail(c, 403, "无权操作此公告")
|
||||
}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// touchDailyLogin 补齐“当天已登录”标记:JWT 有效期较长且可静默续期,
|
||||
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
|
||||
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
|
||||
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
|
||||
// 写入当天登录时间的同时同步记录该次访问的客户端 IP(utils.ClientIP),供后台列表展示来源。
|
||||
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
|
||||
func touchDailyLogin(c *fiber.Ctx, userID uint) {
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
database.DB.Model(&model.SysUser{}).
|
||||
Where("id = ? AND (last_login_at IS NULL OR last_login_at < ?)", userID, todayStart).
|
||||
Updates(map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
})
|
||||
}
|
||||
|
||||
func AuthRequired() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
tokenStr := ""
|
||||
@@ -34,10 +54,26 @@ func AuthRequired() fiber.Handler {
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "token无效或已过期")
|
||||
}
|
||||
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
|
||||
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
|
||||
//(由各 handler 自行兜底),避免误伤。
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
|
||||
if u.Status == "disabled" {
|
||||
reason := strings.TrimSpace(u.DisabledReason)
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
|
||||
}
|
||||
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
|
||||
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
|
||||
}
|
||||
}
|
||||
// 将用户信息存入 context
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
c.Locals("schoolId", claims.SchoolID)
|
||||
// 当天首次使用平台时把 last_login_at 顶到今天并记录 IP(详见 touchDailyLogin 注释)
|
||||
touchDailyLogin(c, claims.UserID)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ type Announcement struct {
|
||||
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
|
||||
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
|
||||
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// 置顶公告:在打卡页顶部横向滚动展示
|
||||
IsPinned bool `gorm:"default:false;index" json:"isPinned"`
|
||||
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Announcement) TableName() string {
|
||||
|
||||
@@ -4,17 +4,22 @@ import "time"
|
||||
|
||||
// CDKCode CDK码
|
||||
type CDKCode struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数
|
||||
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
|
||||
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
|
||||
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数(type=count 有效)
|
||||
// CDK 类型:count=执行次数 / vip=VIP 时长
|
||||
Type string `gorm:"type:varchar(16);not null;default:count;index" json:"type"`
|
||||
// VIP 时长天数(type=vip 有效,默认 30 天/一个月)
|
||||
VipDays int `gorm:"default:0" json:"vipDays"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
|
||||
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
|
||||
UsedAt *time.Time `json:"usedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (CDKCode) TableName() string {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CpLeaveLog 请假/销假执行日志
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"日志时仅标记 deleted_at,
|
||||
// 前台日志列表不再展示,但后台统计仍计入。
|
||||
type CpLeaveLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
LeaveID string `gorm:"type:varchar(64);not null" json:"leaveId"` // 今日校园请假记录 ID(请假申请为 apply_<时间戳>)
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 日志标题(请假类型名 + 动作)
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"` // 学校名称
|
||||
Action string `gorm:"type:varchar(16);not null;default:report" json:"action"` // report=销假 / apply=请假
|
||||
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||
ErrorMessage string `gorm:"type:text" json:"errorMessage"` // 失败时的错误信息
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (CpLeaveLog) TableName() string {
|
||||
return "cp_leave_logs"
|
||||
}
|
||||
|
||||
// 日志动作类型:请假申请 / 销假
|
||||
const (
|
||||
LeaveActionApply = "apply" // 请假申请
|
||||
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
|
||||
LeaveActionReport = "report" // 销假
|
||||
)
|
||||
@@ -6,10 +6,10 @@ import "time"
|
||||
type CpTask struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check, return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识(return_register 为 "taskWid:stuWid")
|
||||
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
|
||||
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
|
||||
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
|
||||
@@ -47,6 +47,10 @@ const (
|
||||
ItemTypeMulti CpTaskItemType = 3 // 多选
|
||||
ItemTypeImage CpTaskItemType = 4 // 图片
|
||||
ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度
|
||||
// 以下类型来自今日校园字段定义(工作日志等场景):原样透传,前端按类型渲染控件
|
||||
ItemTypeDate CpTaskItemType = 5 // 日期/时间
|
||||
ItemTypeDateTime CpTaskItemType = 7 // 日期时间
|
||||
ItemTypeTextarea CpTaskItemType = 9 // 多行文本
|
||||
)
|
||||
|
||||
// CpTaskFormOption 表单选项
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CpTaskLog 任务执行日志
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"日志时仅标记 deleted_at,
|
||||
// 前台日志列表不再展示,但后台统计(已执行次数/今日状态/执行总次数)仍计入。
|
||||
type CpTaskLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
|
||||
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||
ErrorMessage string `gorm:"type:text" json:"errorMessage"` // 失败时的错误信息
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check / return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
|
||||
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||
ErrorMessage string `gorm:"type:text" json:"errorMessage"` // 失败时的错误信息
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (CpTaskLog) TableName() string {
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
|
||||
// PendingSchool 待适配学校(用户提交的测试账号)
|
||||
type PendingSchool struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||
Username string `gorm:"size:100;not null" json:"username"`
|
||||
Password string `gorm:"size:200;not null" json:"password"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||
Username string `gorm:"size:100;not null" json:"username"`
|
||||
Password string `gorm:"size:200;not null" json:"password"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||
RejectReason string `gorm:"size:500" json:"rejectReason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (PendingSchool) TableName() string {
|
||||
|
||||
@@ -3,15 +3,15 @@ package model
|
||||
import "time"
|
||||
|
||||
type SysUser struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||
Cookie string `gorm:"type:longtext" json:"-"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||
Cookie string `gorm:"type:longtext" json:"-"`
|
||||
// RawPassword AES加密后的cpdaily明文密码(base64编码),用于cookie过期时自动重新登录
|
||||
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||
|
||||
// 今日校园真实姓名
|
||||
RealName string `gorm:"type:varchar(255)" json:"realName"`
|
||||
@@ -35,10 +35,14 @@ type SysUser struct {
|
||||
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
|
||||
// 设备码类型 random/authorize/manual
|
||||
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
|
||||
// 可签到次数
|
||||
SignCount int `gorm:"type:int;default:0" json:"signCount"`
|
||||
// 可签到次数(支持小数;每次打卡/请假/销假按后台配置的额度扣减,0 表示不扣费)
|
||||
SignCount float64 `gorm:"type:decimal(12,2);default:0" json:"signCount"`
|
||||
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
|
||||
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
|
||||
// 邀请码
|
||||
InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"`
|
||||
// 邀请人用户ID(null=非邀请注册)
|
||||
InviterID *uint `gorm:"type:int;index" json:"inviterId"`
|
||||
// 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到)
|
||||
InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"`
|
||||
// 累计邀请人数
|
||||
@@ -52,6 +56,12 @@ type SysUser struct {
|
||||
// 最后登录时间
|
||||
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
|
||||
|
||||
// 最后登录 IP(真实登录或当天首次使用平台时记录;nginx 转发 X-Real-IP/X-Forwarded-For)
|
||||
LastLoginIP string `gorm:"type:varchar(64);default:''" json:"lastLoginIp"`
|
||||
|
||||
// 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导,跨设备生效)
|
||||
GuideVersion string `gorm:"type:varchar(32);default:''" json:"guideVersion"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Ticket 工单
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"工单时仅标记 deleted_at,
|
||||
// 用户端列表不再展示,但管理端仍可查看(并标记「用户已删除」),
|
||||
// 保留工单内容与其全部消息记录,避免用户删除后抹掉处理痕迹。
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string {
|
||||
|
||||
@@ -9,6 +9,7 @@ type TicketNotification struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TicketID uint `gorm:"not null;index" json:"ticketId"`
|
||||
Kind string `gorm:"type:varchar(16);not null;default:reply;index" json:"kind"` // reply(管理员回复) / status(状态变更)
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"size:500;not null" json:"content"`
|
||||
IsRead bool `gorm:"not null;default:false;index" json:"isRead"`
|
||||
|
||||
+453
-297
@@ -2,28 +2,145 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"cpdaily-new/internal/assets"
|
||||
"cpdaily-new/internal/config"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
var (
|
||||
captchaStore = sync.Map{}
|
||||
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
captchaStore = sync.Map{} // Redis 不可用时的兜底存储(进程级,带过期懒清理)
|
||||
)
|
||||
|
||||
const (
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaKeyFmt = "captcha:%s" // 验证码内容
|
||||
captchaRLKey = "captcha:rl:%s"
|
||||
|
||||
// 验证码四周统一留白(px):文字、干扰线、装饰全部限制在该留白区内
|
||||
captchaMargin = 15
|
||||
|
||||
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
|
||||
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
|
||||
captchaBucketCapacity = 10
|
||||
captchaBucketWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// 令牌桶 Lua 脚本(原子):状态存 "剩余tokens:上次补桶毫秒时间戳"
|
||||
// 返回 {1,0} 放行(已扣 1 个 token);{0, waitMs} 被限流(需等待 waitMs 毫秒后才有新 token)
|
||||
var captchaTokenScript = redis.NewScript(`
|
||||
local cap = tonumber(ARGV[1])
|
||||
local rateMs = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local windowMs = tonumber(ARGV[4])
|
||||
|
||||
local tokens = cap
|
||||
local last = now
|
||||
local data = redis.call('GET', KEYS[1])
|
||||
if data then
|
||||
local colon = string.find(data, ':')
|
||||
if colon then
|
||||
tokens = tonumber(string.sub(data, 1, colon - 1))
|
||||
last = tonumber(string.sub(data, colon + 1))
|
||||
if not tokens or not last then tokens = cap end
|
||||
end
|
||||
end
|
||||
|
||||
local elapsedMs = now - last
|
||||
if elapsedMs > 0 then
|
||||
tokens = tokens + elapsedMs * rateMs
|
||||
end
|
||||
if tokens > cap then tokens = cap end
|
||||
|
||||
if tokens < 1 then
|
||||
local waitMs = math.ceil((1 - tokens) / rateMs)
|
||||
if waitMs < 1000 then waitMs = 1000 end
|
||||
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
|
||||
return { 0, waitMs }
|
||||
end
|
||||
|
||||
tokens = tokens - 1
|
||||
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
|
||||
return { 1, 0 }
|
||||
`)
|
||||
|
||||
// redisCaptchaOp 短超时上下文(不阻塞请求)
|
||||
func redisCaptchaCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 2*time.Second)
|
||||
}
|
||||
|
||||
// storeCaptchaCode 存储验证码(优先 Redis,Redis 不可用时兜底内存)
|
||||
func storeCaptchaCode(captchaID, code string) error {
|
||||
if Redis != nil {
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
if err := Redis.Set(ctx, fmt.Sprintf(captchaKeyFmt, captchaID), code, captchaTTL).Err(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
slog.Warn("验证码写入 Redis 失败,降级内存存储", "err", err)
|
||||
}
|
||||
}
|
||||
captchaStore.Store(captchaID, CaptchaValue{
|
||||
Code: code,
|
||||
ExpiresAt: time.Now().Add(captchaTTL),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type CaptchaResult struct {
|
||||
CaptchaID string `json:"captchaId"`
|
||||
ImageBase64 string `json:"imageBase64"`
|
||||
}
|
||||
|
||||
// ───── 字体 ─────
|
||||
|
||||
var (
|
||||
captchaFontOnce sync.Once
|
||||
captchaFont *opentype.Font
|
||||
)
|
||||
|
||||
// loadCaptchaFont 进程内解析一次 Sour Gummy ExtraBold 字体(圆润软糖风格,OFL 开源授权)。
|
||||
// 字体经 go:embed 编译进二进制,解析失败属于构建产物损坏,直接 panic 快速暴露。
|
||||
func loadCaptchaFont() *opentype.Font {
|
||||
captchaFontOnce.Do(func() {
|
||||
f, err := opentype.Parse(assets.SourGummyDigits)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("验证码字体解析失败: %v", err))
|
||||
}
|
||||
captchaFont = f
|
||||
})
|
||||
return captchaFont
|
||||
}
|
||||
|
||||
// captchaFace 按指定字号创建字体 face
|
||||
func captchaFace(size float64) font.Face {
|
||||
face, err := opentype.NewFace(loadCaptchaFont(), &opentype.FaceOptions{
|
||||
Size: size,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("创建验证码字体 face 失败: %v", err))
|
||||
}
|
||||
return face
|
||||
}
|
||||
|
||||
// 二次元风格配色 — 亮色系
|
||||
var animeColors = []color.RGBA{
|
||||
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
|
||||
@@ -50,115 +167,128 @@ var pastelBG = []color.RGBA{
|
||||
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
|
||||
}
|
||||
|
||||
// softCol 生成半透明直线 alpha 颜色(color.NRGBA)。
|
||||
// 必须用 NRGBA 而非给 RGBA 强改 A:image.RGBA 是预乘格式,通道值大于 alpha 属于非法
|
||||
// 预乘值,PNG 编码时按 alpha 除回归会溢出截断,产生黑块/杂色。
|
||||
func softCol(c color.RGBA, a uint8) color.NRGBA {
|
||||
return color.NRGBA{R: c.R, G: c.G, B: c.B, A: a}
|
||||
}
|
||||
|
||||
// 干扰线专用色池:避开红/粉暖色系(用户反馈干扰线太红),取冷色+金色
|
||||
var lineColors = []color.RGBA{
|
||||
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
|
||||
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
|
||||
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
|
||||
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
|
||||
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
|
||||
}
|
||||
|
||||
// 生成随机验证码
|
||||
func generateCode(length int) string {
|
||||
digits := "0123456789"
|
||||
var sb strings.Builder
|
||||
for i := 0; i < length; i++ {
|
||||
sb.WriteByte(digits[rng.Intn(len(digits))])
|
||||
sb.WriteByte(digits[rand.Intn(len(digits))])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 生成图片验证码
|
||||
// 生成图片验证码(可爱风:Sour Gummy 圆润数字 + 白描边贴纸字 + 马卡龙背景 + 弯曲斜线干扰线 + 糖珠点缀)
|
||||
func GenerateCaptcha() (*CaptchaResult, error) {
|
||||
cfg := config.Global.Captcha
|
||||
code := generateCode(cfg.Length)
|
||||
|
||||
// 生成图片
|
||||
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
|
||||
// 画布最小尺寸保障:config.toml 服务器侧独立维护、可能未同步新尺寸,
|
||||
// 低于该值时 15px 四周留白会挤占内容区,代码层强制放大
|
||||
w, h := cfg.Width, cfg.Height
|
||||
if w < 180 {
|
||||
w = 180
|
||||
}
|
||||
if h < 60 {
|
||||
h = 60
|
||||
}
|
||||
|
||||
// 随机马卡龙色背景
|
||||
bg := pastelBG[rng.Intn(len(pastelBG))]
|
||||
// 生成图片
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
// 随机马卡龙色背景(铺满整图)
|
||||
bg := pastelBG[rand.Intn(len(pastelBG))]
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||
|
||||
// ── 干扰线(粗线,高不透明度)──
|
||||
for i := 0; i < 10; i++ {
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = uint8(rng.Intn(80) + 150) // alpha 150~230
|
||||
// ── 四周统一留白 15px:所有内容元素均限制在留白区内 ──
|
||||
m := captchaMargin
|
||||
ix0, iy0 := m, m
|
||||
ix1, iy1 := w-m, h-m
|
||||
boxW, boxH := ix1-ix0, iy1-iy0
|
||||
|
||||
x1 := rng.Intn(cfg.Width)
|
||||
y1 := rng.Intn(cfg.Height)
|
||||
x2 := rng.Intn(cfg.Width)
|
||||
y2 := rng.Intn(cfg.Height)
|
||||
|
||||
thick := rng.Intn(2) + 2 // 2~3px 粗
|
||||
drawThickLine(img, x1, y1, x2, y2, col, thick)
|
||||
// ── 背景装饰:白色半透明大圆(软糖气泡感)──
|
||||
for i := 0; i < 2; i++ {
|
||||
col := softCol(color.RGBA{255, 255, 255, 255}, uint8(rand.Intn(14)+18))
|
||||
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(12)+14, col)
|
||||
}
|
||||
|
||||
// ── 二次元风格彩色折线 ──
|
||||
for i := 0; i < 6; i++ {
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = uint8(rng.Intn(60) + 130)
|
||||
|
||||
x1 := rng.Intn(cfg.Width)
|
||||
y1 := rng.Intn(cfg.Height)
|
||||
xm := rng.Intn(cfg.Width)
|
||||
ym := rng.Intn(cfg.Height)
|
||||
x2 := rng.Intn(cfg.Width)
|
||||
y2 := rng.Intn(cfg.Height)
|
||||
drawThickLine(img, x1, y1, xm, ym, col, 2)
|
||||
drawThickLine(img, xm, ym, x2, y2, col, 2)
|
||||
// ── 糖珠点缀:实心小圆 + 空心泡泡 ──
|
||||
for i := 0; i < 5; i++ {
|
||||
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(60)+90))
|
||||
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(2)+1, col)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(40)+70))
|
||||
drawRing(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(3)+3, col)
|
||||
}
|
||||
|
||||
// ── 装饰圆圈(半透明彩色气泡)──
|
||||
for i := 0; i < 4; i++ {
|
||||
cx := rng.Intn(cfg.Width)
|
||||
cy := rng.Intn(cfg.Height)
|
||||
radius := rng.Intn(5) + 3
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = 40
|
||||
drawCircle(img, cx, cy, radius, col)
|
||||
// ── 字符:Sour Gummy 圆润字体渲染数字,白描边贴纸风 + 随机旋转 ±10° ──
|
||||
// 数字实际字高约 0.65em,字号取内容高的 1.25 倍让字符饱满贴近留白线;
|
||||
// 并以每字符可用宽度约束上限
|
||||
fontSize := float64(boxH) * 1.25
|
||||
if per := float64(boxW) / float64(cfg.Length) / 0.6 * 1.1; fontSize > per {
|
||||
fontSize = per
|
||||
}
|
||||
face := captchaFace(fontSize)
|
||||
defer face.Close()
|
||||
|
||||
for i, c := range code {
|
||||
centerX := ix0 + boxW*(2*i+1)/(2*cfg.Length)
|
||||
centerY := iy0 + boxH/2
|
||||
col := animeColors[rand.Intn(len(animeColors))]
|
||||
angle := rand.Float64()*20 - 10 // -10° ~ +10°
|
||||
drawStickerChar(img, face, centerX, centerY, string(c), col, angle)
|
||||
}
|
||||
|
||||
// 小星星/闪光装饰点
|
||||
for i := 0; i < 6; i++ {
|
||||
x := rng.Intn(cfg.Width)
|
||||
y := rng.Intn(cfg.Height)
|
||||
starCol := animeColors[rng.Intn(len(animeColors))]
|
||||
starCol.A = 100
|
||||
drawStarDot(img, x, y, starCol)
|
||||
// 少量柔和彩色噪点(半透明小点)
|
||||
for i := 0; i < 20; i++ {
|
||||
x := ix0 + rand.Intn(boxW)
|
||||
y := iy0 + rand.Intn(boxH)
|
||||
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(50)+40))
|
||||
setPx(img, x, y, col)
|
||||
}
|
||||
|
||||
// 彩色噪点
|
||||
for i := 0; i < 100; i++ {
|
||||
x := rng.Intn(cfg.Width)
|
||||
y := rng.Intn(cfg.Height)
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = uint8(rng.Intn(100) + 80)
|
||||
img.Set(x, y, col)
|
||||
}
|
||||
|
||||
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
|
||||
charBlockW := cfg.Width / cfg.Length
|
||||
charPixelSize := 4 // 4px 像素块
|
||||
charMapW := 7 // 字符矩阵宽度
|
||||
charMapH := 9 // 字符矩阵高度
|
||||
charW := charMapW * charPixelSize
|
||||
charH := charMapH * charPixelSize
|
||||
|
||||
for i, ch := range code {
|
||||
// 每个字符在其块中居中
|
||||
blockCenterX := i*charBlockW + charBlockW/2
|
||||
charX := blockCenterX - charW/2
|
||||
charY := (cfg.Height - charH) / 2
|
||||
|
||||
// 微调位置
|
||||
charX += rng.Intn(5) - 2
|
||||
charY += rng.Intn(3) - 1
|
||||
|
||||
// 每个字符选择鲜艳的二次元颜色
|
||||
charColor := animeColors[rng.Intn(len(animeColors))]
|
||||
|
||||
// 随机倾斜程度 (shear: 每行偏移像素数)
|
||||
shear := rng.Intn(2) + 1 // 1~2
|
||||
|
||||
// 1/3 概率向右倾斜,2/3 向左倾斜
|
||||
if rng.Intn(3) != 0 {
|
||||
shear = -shear
|
||||
// ── 干扰线:贯穿式弯曲斜线(左右端随机高度 + 强制大倾角 + 四向随机,
|
||||
// 线条本身带 1~2 个正弦拱——单拱似抛物线,双拱似波浪;绘制在数字上层)──
|
||||
for i := 0; i < 3; i++ {
|
||||
col := softCol(lineColors[rand.Intn(len(lineColors))], uint8(rand.Intn(40)+60))
|
||||
y1 := iy0 + rand.Intn(boxH+1)
|
||||
y2 := iy0 + rand.Intn(boxH+1)
|
||||
// 保证“很斜”:两端高度差至少内容高的一半;差值方向即上斜/下斜随机
|
||||
if dy := y2 - y1; abs(dy) < boxH/2 {
|
||||
step := boxH/2 + rand.Intn(boxH/4+1)
|
||||
if dy < 0 {
|
||||
y2 = y1 - step // 右端更低 → 左高右低(下斜)
|
||||
if y2 < iy0 {
|
||||
y2, y1 = iy0, min(iy0+step, iy1)
|
||||
}
|
||||
} else {
|
||||
y2 = y1 + step // 右端更高 → 左低右高(上斜)
|
||||
if y2 > iy1 {
|
||||
y2, y1 = iy1, max(iy1-step, iy0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
|
||||
// 随机交换起止端点,覆盖左斜/右斜方向
|
||||
if rand.Intn(2) == 0 {
|
||||
y1, y2 = y2, y1
|
||||
}
|
||||
drawCurvedLine(img, ix0, y1, ix1, y2, col, 2)
|
||||
}
|
||||
|
||||
// 编码为 PNG base64
|
||||
@@ -170,10 +300,9 @@ func GenerateCaptcha() (*CaptchaResult, error) {
|
||||
|
||||
// 生成唯一 ID 并存储验证码
|
||||
captchaID := generateCode(16)
|
||||
captchaStore.Store(captchaID, CaptchaValue{
|
||||
Code: code,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
if err := storeCaptchaCode(captchaID, code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CaptchaResult{
|
||||
CaptchaID: captchaID,
|
||||
@@ -190,6 +319,19 @@ func VerifyCaptcha(captchaID, code string) bool {
|
||||
if captchaID == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
// 优先 Redis:GETDEL 原子取出并删除(Redis 6.2+)
|
||||
if Redis != nil {
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
val, err := Redis.GetDel(ctx, fmt.Sprintf(captchaKeyFmt, captchaID)).Result()
|
||||
if err == nil {
|
||||
return strings.EqualFold(val, code)
|
||||
}
|
||||
if err != redis.Nil {
|
||||
slog.Warn("验证码校验读取 Redis 失败", "err", err)
|
||||
}
|
||||
// 未命中或出错:再查内存兜底
|
||||
}
|
||||
val, ok := captchaStore.Load(captchaID)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -202,59 +344,232 @@ func VerifyCaptcha(captchaID, code string) bool {
|
||||
return strings.EqualFold(cv.Code, code)
|
||||
}
|
||||
|
||||
// GetCaptchaDebug 读取验证码(调试用)
|
||||
func GetCaptchaDebug(captchaID string) (string, bool) {
|
||||
val, ok := captchaStore.Load(captchaID)
|
||||
if !ok {
|
||||
return "", false
|
||||
// AllowCaptchaToken 验证码生成令牌桶限流:同 IP 5 分钟内最多获取 10 次。
|
||||
// 返回 (true, 0) 表示允许生成并已扣减 token;
|
||||
// 返回 (false, wait) 表示被限流,wait 为最快可再次获取的等待时长(已向上取整到秒)。
|
||||
// 空 IP 或 Redis 不可用/出错时放行(存储兜底同样受限,避免误伤正常用户)。
|
||||
func AllowCaptchaToken(ip string) (bool, time.Duration) {
|
||||
if ip == "" || Redis == nil {
|
||||
return true, 0
|
||||
}
|
||||
cv := val.(CaptchaValue)
|
||||
return cv.Code, true
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
|
||||
rateMs := float64(captchaBucketCapacity) / float64(captchaBucketWindow.Milliseconds())
|
||||
res, err := captchaTokenScript.Run(ctx, Redis,
|
||||
[]string{fmt.Sprintf(captchaRLKey, ip)},
|
||||
captchaBucketCapacity,
|
||||
fmt.Sprintf("%.12f", rateMs),
|
||||
time.Now().UnixMilli(),
|
||||
captchaBucketWindow.Milliseconds(),
|
||||
).Int64Slice()
|
||||
if err != nil {
|
||||
slog.Warn("验证码限流 Redis 操作失败,放行", "err", err)
|
||||
return true, 0
|
||||
}
|
||||
if len(res) != 2 {
|
||||
return true, 0
|
||||
}
|
||||
if res[0] == 1 {
|
||||
return true, 0
|
||||
}
|
||||
// waitMs 向上取整到秒(至少 1 秒)
|
||||
wait := time.Duration(((res[1]+999)/1000)*1000) * time.Millisecond
|
||||
if wait < time.Second {
|
||||
wait = time.Second
|
||||
}
|
||||
return false, wait
|
||||
}
|
||||
|
||||
// FormatWait 将等待时长格式化为提示文案片段:如 "5 秒"、"3 分钟"
|
||||
func FormatWait(d time.Duration) string {
|
||||
secs := int((d + 999*time.Millisecond) / time.Second)
|
||||
switch {
|
||||
case secs < 60:
|
||||
return fmt.Sprintf("%d 秒", secs)
|
||||
case secs < 3600:
|
||||
return fmt.Sprintf("%d 分钟", (secs+59)/60)
|
||||
default:
|
||||
return fmt.Sprintf("%d 小时", (secs+3599)/3600)
|
||||
}
|
||||
}
|
||||
|
||||
// setPx 带边界检查的安全画点
|
||||
func setPx(img *image.RGBA, x, y int, col color.Color) {
|
||||
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
||||
img.Set(x, y, col)
|
||||
}
|
||||
}
|
||||
|
||||
// drawCurvedLine 绘制带弧度的贯穿斜线:沿 (x1,y1)→(x2,y2) 直线走向,
|
||||
// 按线段法向叠加 1~2 个正弦拱偏移(单拱似抛物线、双拱似波浪),拱向随机,
|
||||
// 起止点仍在直线两端——整体保持大倾角四向随机,线条本身带柔和小波浪。
|
||||
func drawCurvedLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
steps := max(abs(dx), abs(dy))
|
||||
if steps == 0 {
|
||||
return
|
||||
}
|
||||
lineLen := math.Hypot(float64(dx), float64(dy))
|
||||
amp := 3 + rand.Intn(4) // 拱高 3~6px
|
||||
arches := 1 + rand.Intn(2) // 1~2 个拱
|
||||
phase := rand.Float64() * 2 * math.Pi
|
||||
flip := 1.0
|
||||
if rand.Intn(2) == 0 {
|
||||
flip = -1.0 // 拱起方向随机(法向正/负)
|
||||
}
|
||||
nx, ny := float64(-dy)/lineLen, float64(dx)/lineLen // 单位法向量
|
||||
for i := 0; i <= steps; i++ {
|
||||
t := float64(i) / float64(steps)
|
||||
cx := float64(x1) + float64(dx)*t
|
||||
cy := float64(y1) + float64(dy)*t
|
||||
off := flip * float64(amp) * math.Sin(float64(arches)*2*math.Pi*t+phase)
|
||||
px := int(cx + nx*off + 0.5)
|
||||
py := int(cy + ny*off + 0.5)
|
||||
for k := 0; k < thickness; k++ {
|
||||
setPx(img, px, py+k-thickness/2, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drawRing 绘制空心圆环(泡泡装饰)
|
||||
func drawRing(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||
for step := 0; step < 72; step++ {
|
||||
a := float64(step) * math.Pi / 36
|
||||
x := cx + int(float64(radius)*math.Cos(a)+0.5)
|
||||
y := cy + int(float64(radius)*math.Sin(a)+0.5)
|
||||
setPx(img, x, y, col)
|
||||
}
|
||||
}
|
||||
|
||||
// strokeDirs 白色描边的 12 方向偏移(半径 2px 圆周均匀采样,叠出圆润粗描边)
|
||||
var strokeDirs []image.Point
|
||||
|
||||
func init() {
|
||||
for i := 0; i < 12; i++ {
|
||||
a := float64(i) * math.Pi / 6
|
||||
strokeDirs = append(strokeDirs, image.Point{
|
||||
int(math.Round(2 * math.Cos(a))),
|
||||
int(math.Round(2 * math.Sin(a))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// drawStickerChar 以 (cx,cy) 为中心绘制“贴纸风”字符:
|
||||
// Sour Gummy 圆润字形 + 12 方向白色粗描边(贴纸效果)+ 糖果色填充,
|
||||
// 再按 angleDeg 旋转后以 alpha 混合贴到底图。
|
||||
// 按字形实际包围盒(而非 ascent/descent 行高)在临时画布中精确居中——
|
||||
// 数字无下降部,若按行高定位会整体偏上、下方留白偏大。
|
||||
func drawStickerChar(dst *image.RGBA, face font.Face, cx, cy int, char string, col color.RGBA, angleDeg float64) {
|
||||
// 测量字形包围盒(相对绘制原点 Dot 的 26.6 定点坐标)
|
||||
gb, _, _, _, ok := face.Glyph(fixed.Point26_6{}, []rune(char)[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
glyphW := gb.Dx()
|
||||
glyphH := gb.Dy()
|
||||
|
||||
pad := 4 // 描边 + 旋转余量
|
||||
tmpW, tmpH := glyphW+pad*2, glyphH+pad*2
|
||||
tmp := image.NewRGBA(image.Rect(0, 0, tmpW, tmpH))
|
||||
|
||||
// 平移 Dot 使字形包围盒左上角恰好落在 (pad,pad),即字形在画布中精确居中
|
||||
baseDX := fixed.I(pad - gb.Min.X)
|
||||
baseDY := fixed.I(pad - gb.Min.Y)
|
||||
|
||||
// 白色描边:12 方向偏移叠加
|
||||
white := image.NewUniform(color.RGBA{255, 255, 255, 255})
|
||||
for _, off := range strokeDirs {
|
||||
fd := &font.Drawer{
|
||||
Dst: tmp, Src: white, Face: face,
|
||||
Dot: fixed.Point26_6{X: baseDX + fixed.I(off.X), Y: baseDY + fixed.I(off.Y)},
|
||||
}
|
||||
fd.DrawString(char)
|
||||
}
|
||||
// 糖果色主体
|
||||
fd := &font.Drawer{
|
||||
Dst: tmp, Src: image.NewUniform(col), Face: face,
|
||||
Dot: fixed.Point26_6{X: baseDX, Y: baseDY},
|
||||
}
|
||||
fd.DrawString(char)
|
||||
|
||||
rotateOverlay(dst, tmp, cx, cy, angleDeg)
|
||||
}
|
||||
|
||||
// rotateOverlay 将透明背景的 src 以 (cx,cy) 为中心旋转 angleDeg 后混合到 dst。
|
||||
// 反向映射 + 双线性采样:1:1 旋转下无空洞、边缘平滑无锯齿。
|
||||
func rotateOverlay(dst, src *image.RGBA, cx, cy int, angleDeg float64) {
|
||||
a := angleDeg * math.Pi / 180
|
||||
cos, sin := math.Cos(a), math.Sin(a)
|
||||
srcCx := float64(src.Bounds().Dx()) / 2
|
||||
srcCy := float64(src.Bounds().Dy()) / 2
|
||||
|
||||
radius := int(math.Hypot(srcCx, srcCy)) + 1
|
||||
db := dst.Bounds()
|
||||
for y := cy - radius; y <= cy+radius; y++ {
|
||||
if y < db.Min.Y || y >= db.Max.Y {
|
||||
continue
|
||||
}
|
||||
for x := cx - radius; x <= cx+radius; x++ {
|
||||
if x < db.Min.X || x >= db.Max.X {
|
||||
continue
|
||||
}
|
||||
dx, dy := float64(x-cx), float64(y-cy)
|
||||
sx := cos*dx + sin*dy + srcCx
|
||||
sy := -sin*dx + cos*dy + srcCy
|
||||
sr, sg, sb, sa := sampleBilinear(src, sx, sy)
|
||||
if sa <= 0 {
|
||||
continue
|
||||
}
|
||||
// 预乘 alpha 域的 over 混合:out = src + dst*(1-α)
|
||||
af := sa / 255
|
||||
dr, dg, dbb, da := dst.At(x, y).RGBA()
|
||||
nr := sr + float64(dr>>8)*(1-af)
|
||||
ng := sg + float64(dg>>8)*(1-af)
|
||||
nb := sb + float64(dbb>>8)*(1-af)
|
||||
na := sa + float64(da>>8)*(1-af)
|
||||
dst.Set(x, y, color.RGBA{uint8(nr), uint8(ng), uint8(nb), uint8(na)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sampleBilinear 对预乘 alpha 的 RGBA 图做双线性采样,返回 0-255 范围的预乘 RGBA
|
||||
func sampleBilinear(src *image.RGBA, sx, sy float64) (r, g, b, a float64) {
|
||||
bnd := src.Bounds()
|
||||
x0, y0 := int(math.Floor(sx)), int(math.Floor(sy))
|
||||
fx, fy := sx-float64(x0), sy-float64(y0)
|
||||
if sx < 0 || sy < 0 || sx >= float64(bnd.Max.X) || sy >= float64(bnd.Max.Y) {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
for dy := 0; dy <= 1; dy++ {
|
||||
for dx := 0; dx <= 1; dx++ {
|
||||
px, py := x0+dx, y0+dy
|
||||
if px < bnd.Min.X || px >= bnd.Max.X || py < bnd.Min.Y || py >= bnd.Max.Y {
|
||||
continue
|
||||
}
|
||||
wx := 1 - fx
|
||||
if dx == 1 {
|
||||
wx = fx
|
||||
}
|
||||
wy := 1 - fy
|
||||
if dy == 1 {
|
||||
wy = fy
|
||||
}
|
||||
w := wx * wy
|
||||
if w == 0 {
|
||||
continue
|
||||
}
|
||||
c := src.RGBAAt(px, py)
|
||||
r += float64(c.R) * w
|
||||
g += float64(c.G) * w
|
||||
b += float64(c.B) * w
|
||||
a += float64(c.A) * w
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 画粗线(绘制多条平行线模拟粗线)
|
||||
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
steps := max(abs(dx), abs(dy))
|
||||
if steps == 0 {
|
||||
return
|
||||
}
|
||||
for i := 0; i <= steps; i++ {
|
||||
cx := x1 + dx*i/steps
|
||||
cy := y1 + dy*i/steps
|
||||
// 在垂直方向偏移绘制粗线效果
|
||||
for t := -(thickness - 1); t < thickness; t++ {
|
||||
// 根据线的方向决定偏移方向
|
||||
var px, py int
|
||||
if abs(dx) > abs(dy) {
|
||||
px = cx
|
||||
py = cy + t
|
||||
} else {
|
||||
px = cx + t
|
||||
py = cy
|
||||
}
|
||||
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
||||
img.Set(px, py, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 划线(单像素)
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
steps := max(abs(dx), abs(dy))
|
||||
if steps == 0 {
|
||||
return
|
||||
}
|
||||
for i := 0; i <= steps; i++ {
|
||||
x := x1 + dx*i/steps
|
||||
y := y1 + dy*i/steps
|
||||
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
||||
img.Set(x, y, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制半透明圆圈
|
||||
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||
@@ -271,165 +586,6 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制小星星闪光点
|
||||
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
|
||||
offsets := [][2]int{
|
||||
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
||||
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
|
||||
}
|
||||
for _, off := range offsets {
|
||||
px := x + off[0]
|
||||
py := y + off[1]
|
||||
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
||||
img.Set(px, py, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
|
||||
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
|
||||
// 7x9 数字点阵映射
|
||||
charMaps := map[string][][]int{
|
||||
"0": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"1": {
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
{0, 1, 1, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"2": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"3": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"4": {
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 1, 1, 1, 0},
|
||||
{0, 0, 1, 1, 1, 1, 0},
|
||||
{0, 1, 1, 0, 1, 1, 0},
|
||||
{1, 1, 0, 0, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
},
|
||||
"5": {
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"6": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"7": {
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 1, 1, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
},
|
||||
"8": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"9": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
}
|
||||
|
||||
pattern, ok := charMaps[char]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// shear: 每行相对中心行(4)的水平偏移
|
||||
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
|
||||
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
|
||||
for py, row := range pattern {
|
||||
// 行偏移量 = (py - 4) * shear
|
||||
rowShear := (py - 4) * shear
|
||||
|
||||
for px, v := range row {
|
||||
if v == 1 {
|
||||
ix := x + px*pixelSize + rowShear
|
||||
iy := y + py*pixelSize
|
||||
for dx := 0; dx < pixelSize; dx++ {
|
||||
for dy := 0; dy < pixelSize; dy++ {
|
||||
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
|
||||
img.Set(ix+dx, iy+dy, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// simpleCookieJar 简化的 CookieJar 实现
|
||||
@@ -29,6 +30,20 @@ func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
cookieMap[c.Name] = c
|
||||
}
|
||||
for _, c := range cookies {
|
||||
// 删除指令(Max-Age<=0 或 Expires 已过)或空值:一律跳过,既不入库也不删除
|
||||
// 已有同名 cookie。simpleCookieJar 不记录 Path/Domain,无法按 RFC 6265 精确
|
||||
// 匹配删除指令的作用域。云大 CAS 登录 POST 成功后先发
|
||||
// CASTGC=TGT-...; Path=/authserver
|
||||
// 再发删除指令
|
||||
// CASTGC=; Max-Age=0; path=/authserver/ (注意尾部斜杠,与 TGT 的 Path 不同)
|
||||
// 浏览器依 RFC 6265 不会删除 TGT,后续 MFA 页面(reAuthLoginView.do)依赖该
|
||||
// TGT 判定 SSO 会话;若按名字粗暴删除 TGT,MFA 页会被 302 踢回登录页。
|
||||
// 若把空值 CASTGC 存进 jar 发送空值,同样会被判定会话异常(历史 bug)。
|
||||
// 跳过删除指令可同时避免这两种问题;同名残留会在下次登录时被 CAS 重新
|
||||
// Set-Cookie 覆盖,无实际副作用。
|
||||
if c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) || c.Value == "" {
|
||||
continue
|
||||
}
|
||||
cookieMap[c.Name] = c
|
||||
}
|
||||
|
||||
@@ -67,3 +82,18 @@ func (j *simpleCookieJar) AllCookies() []*http.Cookie {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AllCookiesByHost 按域名分组返回所有 cookie(MFA 会话暂存用,保留域结构以便恢复)
|
||||
func (j *simpleCookieJar) AllCookiesByHost() map[string][]*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
result := make(map[string][]*http.Cookie, len(j.cookies))
|
||||
for host, cookies := range j.cookies {
|
||||
if len(cookies) > 0 {
|
||||
cp := make([]*http.Cookie, len(cookies))
|
||||
copy(cp, cookies)
|
||||
result[host] = cp
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// evalFaceIdentify 判定人脸识别复核结果。
|
||||
func TestEvalFaceIdentify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows map[string]interface{}
|
||||
wantFace bool
|
||||
wantRejected bool
|
||||
wantSettled bool
|
||||
}{
|
||||
{
|
||||
name: "被系统驳回(FRS.0036)",
|
||||
rows: map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(2),
|
||||
"recognitionProgress": "系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
|
||||
"signStatus": "0",
|
||||
},
|
||||
wantFace: true,
|
||||
wantRejected: true,
|
||||
wantSettled: true,
|
||||
},
|
||||
{
|
||||
name: "人脸核验成功(progress=null/code=0, signStatus=4)",
|
||||
rows: map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": nil,
|
||||
"recognitionProgress": nil,
|
||||
"signStatus": "4",
|
||||
},
|
||||
wantFace: true,
|
||||
wantRejected: false,
|
||||
wantSettled: true,
|
||||
},
|
||||
{
|
||||
name: "尚未出结果(进行中, signStatus=0)",
|
||||
rows: map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(0),
|
||||
"recognitionProgress": "",
|
||||
"signStatus": "0",
|
||||
},
|
||||
wantFace: true,
|
||||
wantRejected: false,
|
||||
wantSettled: false,
|
||||
},
|
||||
{
|
||||
name: "非人脸任务(无 isFaceIdentify)",
|
||||
rows: map[string]interface{}{
|
||||
"signStatus": "4",
|
||||
},
|
||||
wantFace: false,
|
||||
wantRejected: false,
|
||||
wantSettled: true,
|
||||
},
|
||||
{
|
||||
name: "rows 为 nil",
|
||||
rows: nil,
|
||||
wantFace: false,
|
||||
wantRejected: false,
|
||||
wantSettled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := evalFaceIdentify(c.rows)
|
||||
if got.IsFaceIdentify != c.wantFace {
|
||||
t.Fatalf("IsFaceIdentify=%v, want %v", got.IsFaceIdentify, c.wantFace)
|
||||
}
|
||||
if got.Rejected != c.wantRejected {
|
||||
t.Fatalf("Rejected=%v, want %v", got.Rejected, c.wantRejected)
|
||||
}
|
||||
if got.Settled != c.wantSettled {
|
||||
t.Fatalf("Settled=%v, want %v", got.Settled, c.wantSettled)
|
||||
}
|
||||
if c.wantRejected && got.Message == "" {
|
||||
t.Fatal("驳回时 Message 不应为空")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsFaceIdentify 判断任务是否开启人脸识别。
|
||||
func TestNeedsFaceIdentify(t *testing.T) {
|
||||
if !NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(1)}) {
|
||||
t.Fatal("isFaceIdentify=1 应判为需要人脸识别")
|
||||
}
|
||||
if NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(0)}) {
|
||||
t.Fatal("isFaceIdentify=0 不应判为需要人脸识别")
|
||||
}
|
||||
if NeedsFaceIdentify(map[string]interface{}{"signStatus": "4"}) {
|
||||
t.Fatal("无 isFaceIdentify 字段不应判为需要人脸识别")
|
||||
}
|
||||
if NeedsFaceIdentify(nil) {
|
||||
t.Fatal("nil rows 不应判为需要人脸识别")
|
||||
}
|
||||
}
|
||||
|
||||
// rawCodeString 归一化 code 方言(字符串/数字/缺省)。
|
||||
func TestRawCodeString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{`"0"`, "0"},
|
||||
{`"5"`, "5"},
|
||||
{`0`, "0"},
|
||||
{`1`, "1"},
|
||||
{``, ""},
|
||||
{`null`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := rawCodeString([]byte(c.in))
|
||||
if got != c.want {
|
||||
t.Fatalf("rawCodeString(%s)=%q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 线上实证:人脸识别在服务端排队时,recognitionProgress 会返回
|
||||
// 「识别中,预计等待N位」。这类文案表示仍在进行,绝不能判为驳回。
|
||||
// 依据 162 服务器日志(v1.2.160 之前版本误判为签到失败)。
|
||||
func TestEvalFaceIdentifyQueueing(t *testing.T) {
|
||||
cases := []string{
|
||||
"识别中,预计等待4位",
|
||||
"识别中,预计等待8位",
|
||||
"识别中,预计等待1位",
|
||||
"识别中",
|
||||
"排队中,请稍候",
|
||||
"处理中",
|
||||
"预计等待3位",
|
||||
}
|
||||
for _, msg := range cases {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": msg,
|
||||
"recognitionProgressCode": float64(0),
|
||||
"signStatus": "0",
|
||||
})
|
||||
if got.Rejected {
|
||||
t.Fatalf("排队文案 %q 不应判为驳回", msg)
|
||||
}
|
||||
if got.Settled {
|
||||
t.Fatalf("排队文案 %q 不应视为已出结果", msg)
|
||||
}
|
||||
if got.Message != msg {
|
||||
t.Fatalf("Message 应透传排队提示,got %q want %q", got.Message, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 真正的失败文案仍必须判驳回,不能因为放宽而漏判。
|
||||
func TestEvalFaceIdentifyRealFailure(t *testing.T) {
|
||||
cases := []string{
|
||||
"系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
|
||||
"人脸识别未通过",
|
||||
"人脸不匹配",
|
||||
"未检测到人脸",
|
||||
"签到失败",
|
||||
}
|
||||
for _, msg := range cases {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": msg,
|
||||
"signStatus": "0",
|
||||
})
|
||||
if !got.Rejected {
|
||||
t.Fatalf("失败文案 %q 应判驳回", msg)
|
||||
}
|
||||
if !got.Settled {
|
||||
t.Fatalf("失败文案 %q 应视为已出结果", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未识别文案保守处理:不确定就当进行中,不误杀正常签到。
|
||||
func TestEvalFaceIdentifyUnknownMessageNotRejected(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": "系统繁忙,请稍后再试一下",
|
||||
"signStatus": "0",
|
||||
})
|
||||
if got.Rejected {
|
||||
t.Fatal("未知文案不应判驳回(保守当进行中)")
|
||||
}
|
||||
}
|
||||
|
||||
// recognitionProgressCode=2 仍应独立判驳回(无文案时靠码判断)。
|
||||
func TestEvalFaceIdentifyCode2WithoutMessage(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(2),
|
||||
"recognitionProgress": "",
|
||||
"signStatus": "0",
|
||||
})
|
||||
if !got.Rejected || !got.Settled {
|
||||
t.Fatalf("progressCode=2 应判驳回,got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,14 @@ package cpdaily
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
@@ -14,11 +18,12 @@ import (
|
||||
|
||||
// CpImageUploadPolicy 今日校园 OBS 上传策略
|
||||
type CpImageUploadPolicy struct {
|
||||
AccessId string `json:"accessid"`
|
||||
FileName string `json:"fileName"`
|
||||
Policy string `json:"policy"`
|
||||
Host string `json:"host"`
|
||||
Signature string `json:"signature"`
|
||||
AccessId string `json:"accessid"`
|
||||
FileName string `json:"fileName"`
|
||||
Policy string `json:"policy"`
|
||||
Host string `json:"host"`
|
||||
Signature string `json:"signature"`
|
||||
FormData map[string]string `json:"formData"`
|
||||
}
|
||||
|
||||
// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀
|
||||
@@ -26,6 +31,9 @@ func getImageUrlPrefix(taskType TaskType) string {
|
||||
switch taskType {
|
||||
case TaskTypeSleepCheck:
|
||||
return "/wec-counselor-attendance-apps/student/attendance"
|
||||
case TaskTypeLeave:
|
||||
// 销假图片上传走请假模块
|
||||
return "/wec-counselor-leave-apps/leave"
|
||||
case TaskTypeSign:
|
||||
return "/wec-counselor-sign-apps/stu"
|
||||
default:
|
||||
@@ -65,7 +73,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas *CpImageUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Datas == nil {
|
||||
@@ -75,7 +83,11 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
return result.Datas, nil
|
||||
}
|
||||
|
||||
// uploadToOBS 将文件上传到今日校园的 OBS
|
||||
// uploadToOBS 将文件上传到今日校园的 OBS。
|
||||
// 优先使用接口返回的 formData 字段集原样提交:不同任务类型的表单字段不同
|
||||
// (查寝:AccessKeyId/signature/x-obs-acl;签到:q-ak/q-key-time/q-signature 等),
|
||||
// 按旧方式自行拼装字段会导致签到类型上传被 OBS 拒绝(AccessDenied)。
|
||||
// 仅当接口未返回 formData 时回退到通用字段拼装。
|
||||
func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error {
|
||||
client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
|
||||
@@ -87,15 +99,26 @@ func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType strin
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
writer.WriteField("x-obs-acl", "public-read")
|
||||
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||
writer.WriteField("key", policy.FileName)
|
||||
writer.WriteField("signature", policy.Signature)
|
||||
writer.WriteField("policy", policy.Policy)
|
||||
writer.WriteField("content-type", contentType)
|
||||
if len(policy.FormData) > 0 {
|
||||
for k, v := range policy.FormData {
|
||||
writer.WriteField(k, v)
|
||||
}
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="file"; filename="file.jpg"`)
|
||||
header.Set("Content-Type", contentType)
|
||||
part, _ := writer.CreatePart(header)
|
||||
part.Write(fileData)
|
||||
} else {
|
||||
writer.WriteField("x-obs-acl", "public-read")
|
||||
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||
writer.WriteField("key", policy.FileName)
|
||||
writer.WriteField("signature", policy.Signature)
|
||||
writer.WriteField("policy", policy.Policy)
|
||||
writer.WriteField("content-type", contentType)
|
||||
|
||||
part, _ := writer.CreateFormFile("file", "file")
|
||||
part.Write(fileData)
|
||||
part, _ := writer.CreateFormFile("file", "file")
|
||||
part.Write(fileData)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
resp, err := client.R().
|
||||
@@ -123,6 +146,9 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
switch taskType {
|
||||
case TaskTypeSleepCheck:
|
||||
apiPath = "/previewAttachment"
|
||||
case TaskTypeLeave:
|
||||
// 销假图片预览走请假模块 OBS 接口
|
||||
apiPath = "/obs/previewAttachment"
|
||||
case TaskTypeSign:
|
||||
apiPath = "/sign/previewAttachment"
|
||||
default:
|
||||
@@ -145,15 +171,33 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
var result struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w", err)
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
// 部分学校(如 cyprxy 代理域)返回 {"code":"0",...} 方言
|
||||
if len(result.Code) > 0 {
|
||||
var codeStr string
|
||||
if json.Unmarshal(result.Code, &codeStr) == nil {
|
||||
if codeStr != "" && codeStr != "0" {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = result.ErrMsg
|
||||
}
|
||||
return "", fmt.Errorf("获取预览 URL 错误(%s): %s", codeStr, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result.Datas) == 0 || string(result.Datas) == "null" {
|
||||
return "", fmt.Errorf("获取预览 URL 为空(body: %.200s)", string(body))
|
||||
}
|
||||
|
||||
var previewUrl string
|
||||
if err := json.Unmarshal(result.Datas, &previewUrl); err != nil {
|
||||
@@ -164,6 +208,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
}
|
||||
|
||||
// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL
|
||||
// 预览接口偶发返回空响应,做少量重试避免误判失败
|
||||
func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) {
|
||||
policy, err := GetUploadPolicy(cookieMap, host, taskType)
|
||||
if err != nil {
|
||||
@@ -174,17 +219,77 @@ func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fi
|
||||
return "", err
|
||||
}
|
||||
|
||||
previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var previewUrl string
|
||||
var lastErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(time.Duration(i) * time.Second)
|
||||
}
|
||||
previewUrl, lastErr = GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||
if lastErr == nil {
|
||||
return previewUrl, nil
|
||||
}
|
||||
// 业务性错误(错误码/数据为空)无需重试,仅空响应类瞬时错误重试
|
||||
if !strings.Contains(lastErr.Error(), "解析预览 URL 响应失败") && !strings.Contains(lastErr.Error(), "获取预览 URL 为空") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return previewUrl, nil
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// IsDataURIImage 判断内容是否为 base64 图片(前端本地预览,尚未成功上传到 OBS)
|
||||
func IsDataURIImage(content string) bool {
|
||||
return strings.HasPrefix(content, "data:image")
|
||||
}
|
||||
|
||||
// OssKeyFromURL 从已存的 OBS 预览 URL 中提取稳定的对象 key(即 URL path,去掉前导 "/")。
|
||||
// 预览 URL 形如:
|
||||
//
|
||||
// https://<host>/<key>?AccessKeyId=..&Expires=..&Signature=..
|
||||
// https://<host>/<key>?sign=q-sign-algorithm%3D..
|
||||
//
|
||||
// 其中 query(AccessKeyId/Expires/Signature 或 sign)是**一次性签名**,会过期;
|
||||
// 而 path(对象 key)指向同一张图、长期不变。取出 key 后可反复向预览接口换取
|
||||
// 未过期的新 URL(同一张图,无需用户重传)。
|
||||
func OssKeyFromURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
|
||||
// RefreshPreviewURL 用已存预览 URL 里稳定的对象 key,向预览接口重新换取一个未过期的 URL。
|
||||
//
|
||||
// 场景:签到照片的签名由云端签发、有效期很短(可能仅数分钟),而签到任务是持久化的。
|
||||
// 用户建任务当天上传照片、隔日再执行时,原签名早已过期,直接提交会被学校判为签到失败
|
||||
// (如兰州交大返回 2210010000 签到失败)。此处用对象 key 重签即可继续使用同一张照片。
|
||||
func RefreshPreviewURL(cookieMap map[string]string, host string, taskType TaskType, storedURL string) (string, error) {
|
||||
key := OssKeyFromURL(storedURL)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("无法从图片 URL 解析对象 key")
|
||||
}
|
||||
return GetPreviewUrl(cookieMap, host, taskType, key)
|
||||
}
|
||||
|
||||
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
|
||||
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
|
||||
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
|
||||
idx := strings.Index(dataURI, ",")
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("图片数据格式不正确")
|
||||
}
|
||||
imgBytes, err := base64.StdEncoding.DecodeString(dataURI[idx+1:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
return UploadImage(cookieMap, host, taskType, imgBytes, "image/jpeg")
|
||||
}
|
||||
|
||||
func newImgClient() *resty.Client {
|
||||
return resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetTimeout(30 * time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OssKeyFromURL 从已存预览 URL 提取稳定对象 key(query 里的签名会过期,path 不会)。
|
||||
func TestOssKeyFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "腾讯COS风格签名URL(真实样本)",
|
||||
in: `https://wecres.campusphere.net/counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1?AccessKeyId=HPUAX9XCPLECZ7RMD26U&Expires=1791877432&Signature=r7nEh%2BauSO2d4lsga5fzPugOhzw%3D`,
|
||||
want: "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1",
|
||||
},
|
||||
{
|
||||
name: "华为OBS风格 sign= 签名URL(真实样本)",
|
||||
in: `https://counselor-cos-wecres.campusphere.net/counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDMaaNyLVdgmr59AC6qZ7a5QjsnbIo8MOF`,
|
||||
want: "counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png",
|
||||
},
|
||||
{name: "无query", in: "https://h.example.com/a/b/c.jpg", want: "a/b/c.jpg"},
|
||||
{name: "非URL", in: "data:image/png;base64,AAAA", want: ""},
|
||||
{name: "空串", in: "", want: ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := OssKeyFromURL(c.in); got != c.want {
|
||||
t.Errorf("%s: OssKeyFromURL=%q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshPreviewURL 用存储URL里的对象key重新换取未过期URL(走 mock 预览接口)。
|
||||
func TestRefreshPreviewURL(t *testing.T) {
|
||||
const key = "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 断言用的是提取出的 key,而非把整条旧URL再传回去
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if got, _ := body["ossKey"].(string); got != key {
|
||||
t.Errorf("预览接口收到 ossKey=%q, want %q", got, key)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errCode": 0,
|
||||
"datas": "https://wecres.campusphere.net/" + key + "?AccessKeyId=x&Expires=9999999999&Signature=y",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
stored := "https://wecres.campusphere.net/" + key + "?AccessKeyId=old&Expires=1&Signature=old"
|
||||
got, err := RefreshPreviewURL(map[string]string{}, srv.URL, TaskTypeSign, stored)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshPreviewURL 失败: %v", err)
|
||||
}
|
||||
if got == "" || got == stored {
|
||||
t.Fatalf("应返回新的预览URL,实际: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// key 解析不出时明确报错(不静默用坏URL提交)。
|
||||
func TestRefreshPreviewURLBadInput(t *testing.T) {
|
||||
if _, err := RefreshPreviewURL(map[string]string{}, "https://h.example.com", TaskTypeSign, "not-a-url"); err == nil {
|
||||
t.Fatal("无法解析 key 时应报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 销假(请假销假)功能。
|
||||
// 对应今日校园 wec-counselor-leave-apps 模块:
|
||||
//
|
||||
// 待销假列表:POST {host}/wec-counselor-leave-apps/leave/stu/listOther
|
||||
// 请假详情: POST {host}/wec-counselor-leave-apps/leave/stu/detailOther
|
||||
// 审批人列表:POST {host}/wec-counselor-leave-apps/leave/stu/obtainApproveFlow
|
||||
// 提交销假: POST {host}/wec-counselor-leave-apps/leave/report
|
||||
const leaveAPIBase = "/wec-counselor-leave-apps/leave"
|
||||
|
||||
// LeaveReportPlace 学校规定的销假位置(详情 reportPlaces 元素)。
|
||||
// 销假提交的位置应在其中一个位置的 radius(米)范围内;
|
||||
// 老版本取 leaveExtends[0](续假位置)或 detail.applyLocation(请假提交时定位),
|
||||
// 均非学校规定的销假位置,已按真实接口修正为 reportPlaces。
|
||||
type LeaveReportPlace struct {
|
||||
Wid string `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Radius string `json:"radius"` // 允许范围半径(米)
|
||||
CounName string `json:"counName"` // 位置设置人
|
||||
}
|
||||
|
||||
// LeaveApprover 销假可选审批人(leaveType.reportApplyFlow=1 时需要)
|
||||
type LeaveApprover struct {
|
||||
UserWid string `json:"userWid"`
|
||||
UserId string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
}
|
||||
|
||||
// LeaveItem 待销假记录(列表展示 + 销假表单所需开关/规定位置)
|
||||
type LeaveItem struct {
|
||||
ID string `json:"id"` // 请假记录 ID
|
||||
LeaveType string `json:"leaveType"` // 请假类型名称(如 事假/病假)
|
||||
Reason string `json:"reason"` // 请假原因
|
||||
CreateTime string `json:"createTime"` // 请假提交时间
|
||||
ActEndTime string `json:"actEndTime"` // 预计结束时间
|
||||
Status string `json:"status"` // 系统请假状态码(4=已完成,已过滤)
|
||||
// 状态文案(新套有明确文案映射;旧套为空,前端回退本地映射)
|
||||
StatusText string `json:"statusText,omitempty"`
|
||||
// 学校规定的销假位置(详情 reportPlaces)。有值时前端展示可选列表并预填经纬度;
|
||||
// 无值时提示用户手动填写
|
||||
ReportPlaces []LeaveReportPlace `json:"reportPlaces"`
|
||||
// 销假是否需要选择审批人(leaveType.reportApplyFlow=1)
|
||||
ReportApplyFlow bool `json:"reportApplyFlow"`
|
||||
// 销假是否允许上传图片(leaveType.enableAttach=1)
|
||||
EnableAttach bool `json:"enableAttach"`
|
||||
// —— 续假(仅新套)——
|
||||
CanReport bool `json:"canReport"` // 是否可销假(列表含仅可续假的单,前端销假模式据此过滤)
|
||||
CanExtend bool `json:"canExtend"` // 是否可续假(休假中/逾期 且 isAllowExtend)
|
||||
TypeWid string `json:"typeWid,omitempty"`
|
||||
IsLeaveOut string `json:"isLeaveOut,omitempty"`
|
||||
}
|
||||
|
||||
// LeaveDetail 请假详情(提交销假所需字段)
|
||||
type LeaveDetail struct {
|
||||
ReportID string // datas.detail.id,提交表单的 id 字段
|
||||
LeaveType string // datas.leaveType.name
|
||||
LeaveCode string // datas.leaveType.code,提交表单 leaveType 字段(接口返回为数字,统一转纯文本)
|
||||
LeaveCodeNum int // datas.leaveType.code 数字形式(obtainApproveFlow 请求用)
|
||||
StartTime string // datas.detail.startDate(审批流请求用,提交前补秒)
|
||||
EndTime string // datas.detail.endDate
|
||||
ReportApplyFlow bool // 销假需要选择审批人
|
||||
EnableAttach bool // 销假允许上传图片
|
||||
ReportPlaces []LeaveReportPlace
|
||||
// IsAllowedExtend 旧套详情顶层字段:当前是否允许续假(列表接口不返回,只能查详情)
|
||||
IsAllowedExtend bool
|
||||
}
|
||||
|
||||
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
|
||||
func leaveAnyStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// leaveAnyBool 将 interface{} 值转为布尔(兼容数字/字符串/布尔三种返回风格)
|
||||
func leaveAnyBool(v interface{}) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case float64:
|
||||
return t != 0
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return n != 0
|
||||
case string:
|
||||
return t == "1" || strings.EqualFold(t, "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// leaveAnyInt 将 interface{} 值转为整数(兼容数字/字符串两种返回风格)
|
||||
func leaveAnyInt(v interface{}) int {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(t))
|
||||
return n
|
||||
case bool:
|
||||
if t {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// leaveMapStr 从 map 中取字符串值
|
||||
func leaveMapStr(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return leaveAnyStr(m[key])
|
||||
}
|
||||
|
||||
// leaveTimeWithSeconds 将 "2025-10-24 20:45" 补秒为审批流接口要求的 "2025-10-24 20:45:00"
|
||||
func leaveTimeWithSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 16 { // yyyy-MM-dd HH:mm
|
||||
return t + ":00"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// leavePost 通用 POST 请求(带移动端 UA 与 Cookie),返回响应 body;
|
||||
// extension 非空时额外携带 Cpdaily-Extension 签名头(销假提交用)
|
||||
func leavePost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
req := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap))
|
||||
if extension != "" {
|
||||
req = req.SetHeader("Cpdaily-Extension", extension)
|
||||
}
|
||||
resp, err := req.SetBody(body).Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
|
||||
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveList 获取今日校园待销假列表。
|
||||
// 过滤已完成(status=4)的记录;每条记录附带详情中的规定销假位置(reportPlaces)
|
||||
// 与开关(reportApplyFlow/enableAttach)。
|
||||
// 详情获取失败不阻塞列表展示(仅视为无规定位置、无开关)。
|
||||
func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/listOther", map[string]interface{}{
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
"needApproval": 1,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取待销假列表失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
ErrCode int `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("获取待销假列表错误(%d): %s", result.ErrCode, result.Message)
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveItem, 0, len(result.Datas.Rows))
|
||||
for _, row := range result.Datas.Rows {
|
||||
status := leaveMapStr(row, "status")
|
||||
// 待销假列表只保留可销假的记录:
|
||||
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
|
||||
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
|
||||
if status == "4" || status == "3" || status == "10" {
|
||||
continue
|
||||
}
|
||||
id := leaveMapStr(row, "id")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
item := LeaveItem{
|
||||
ID: id,
|
||||
LeaveType: leaveMapStr(row, "leaveType"),
|
||||
Reason: leaveMapStr(row, "leaveReason"),
|
||||
CreateTime: leaveMapStr(row, "createTime"),
|
||||
ActEndTime: leaveMapStr(row, "actEndTime"),
|
||||
Status: status,
|
||||
// 旧套列表已过滤为可销假记录
|
||||
CanReport: true,
|
||||
// 旧套续假:列表行不含该字段,需由详情 detailOther 的 isAllowedExtend 判定(见下)
|
||||
}
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType),以及可续假标记
|
||||
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
|
||||
item.CanExtend = leaveOldCanExtend(status, d.IsAllowedExtend)
|
||||
item.ReportPlaces = d.ReportPlaces
|
||||
item.ReportApplyFlow = d.ReportApplyFlow
|
||||
item.EnableAttach = d.EnableAttach
|
||||
} else {
|
||||
slog.Warn("获取请假详情失败,规定销假位置与开关视为空", "leaveId", id, "error", err)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// leaveOldCanExtend 旧套「是否可续假」判定:与销假同理,需同时满足
|
||||
// 1. 状态处于假期中/待处理阶段(5=即将休假、6=正在休假中;其余 3 已驳回/4 已完成/10 逾期驳回等不可续);
|
||||
// 2. 详情接口返回 isAllowedExtend=true(学校放开该单的续假)。
|
||||
//
|
||||
// 仅凭 isAllowedExtend 不够:已结束/驳回的单服务端也可能不返回该标志,但显式加状态判断更稳。
|
||||
func leaveOldCanExtend(status string, allowedExtend bool) bool {
|
||||
if !allowedExtend {
|
||||
return false
|
||||
}
|
||||
switch status {
|
||||
case "2", "5", "6": // 处理中/即将休假/正在休假中
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLeaveDetail 获取请假详情(提交销假所需字段 + 规定销假位置与开关)
|
||||
func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*LeaveDetail, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/detailOther", map[string]interface{}{
|
||||
"id": leaveID,
|
||||
"needApproval": 1,
|
||||
"uaIsCpadaily": "true",
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取请假详情失败: %w", err)
|
||||
}
|
||||
|
||||
// 注意:leaveType.code 真实返回为数字(如 3/12),必须用 interface{} 承接
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
ErrCode int `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
// 顶层 isAllowedExtend:当前是否允许续假(与 isExtend 不同,isExtend 表示是否为续假单)
|
||||
IsAllowedExtend interface{} `json:"isAllowedExtend"`
|
||||
Detail struct {
|
||||
ID string `json:"id"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
} `json:"detail"`
|
||||
LeaveType struct {
|
||||
Code interface{} `json:"code"`
|
||||
Name string `json:"name"`
|
||||
ReportApplyFlow interface{} `json:"reportApplyFlow"`
|
||||
EnableAttach interface{} `json:"enableAttach"`
|
||||
} `json:"leaveType"`
|
||||
ReportPlaces []struct {
|
||||
Wid interface{} `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude interface{} `json:"longitude"`
|
||||
Latitude interface{} `json:"latitude"`
|
||||
Radius interface{} `json:"radius"`
|
||||
CounName string `json:"counName"`
|
||||
} `json:"reportPlaces"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("获取请假详情错误(%d): %s", result.ErrCode, result.Message)
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if result.Datas.Detail.ID == "" {
|
||||
return nil, fmt.Errorf("没有获取到请假详情信息")
|
||||
}
|
||||
|
||||
codeStr := leaveAnyStr(result.Datas.LeaveType.Code)
|
||||
codeNum, _ := strconv.Atoi(codeStr)
|
||||
d := &LeaveDetail{
|
||||
ReportID: result.Datas.Detail.ID,
|
||||
LeaveType: result.Datas.LeaveType.Name,
|
||||
LeaveCode: codeStr,
|
||||
LeaveCodeNum: codeNum,
|
||||
StartTime: result.Datas.Detail.StartDate,
|
||||
EndTime: result.Datas.Detail.EndDate,
|
||||
ReportApplyFlow: leaveAnyBool(result.Datas.LeaveType.ReportApplyFlow),
|
||||
EnableAttach: leaveAnyBool(result.Datas.LeaveType.EnableAttach),
|
||||
IsAllowedExtend: leaveAnyBool(result.Datas.IsAllowedExtend),
|
||||
}
|
||||
for _, p := range result.Datas.ReportPlaces {
|
||||
d.ReportPlaces = append(d.ReportPlaces, LeaveReportPlace{
|
||||
Wid: leaveAnyStr(p.Wid),
|
||||
Address: p.Address,
|
||||
Longitude: leaveAnyStr(p.Longitude),
|
||||
Latitude: leaveAnyStr(p.Latitude),
|
||||
Radius: leaveAnyStr(p.Radius),
|
||||
CounName: p.CounName,
|
||||
})
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FetchReportApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型使用)。
|
||||
// 复用请假申请的审批流接口;时间必须精确到秒(yyyy-MM-dd HH:mm:ss)。
|
||||
// 优先取 datas.approvers(首节点可选审批人),为空时兜底展开 approveNodes 中的全部审批人。
|
||||
func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum int, startTime, endTime string) ([]LeaveApprover, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/obtainApproveFlow", map[string]interface{}{
|
||||
"leaveType": leaveCodeNum,
|
||||
"startTime": leaveTimeWithSeconds(startTime),
|
||||
"endTime": leaveTimeWithSeconds(endTime),
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取销假审批人失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
ApproveNodes []struct {
|
||||
ApproveNode []struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
} `json:"approveNode"`
|
||||
} `json:"approveNodes"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveApprover, 0, len(result.Datas.Approvers))
|
||||
seen := make(map[string]bool)
|
||||
for _, a := range result.Datas.Approvers {
|
||||
if a.UserWid == "" || seen[a.UserWid] {
|
||||
continue
|
||||
}
|
||||
seen[a.UserWid] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
for _, n := range result.Datas.ApproveNodes {
|
||||
for _, node := range n.ApproveNode {
|
||||
for _, a := range node.Approvers {
|
||||
if a.UserWid == "" || seen[a.UserWid] {
|
||||
continue
|
||||
}
|
||||
seen[a.UserWid] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// BuildLeaveReportForm 构造销假提交表单(bodyString 内容)。
|
||||
// reportLocation 为用户选择/填写的销假地址,为空时兜底第一个规定销假位置地址;
|
||||
// approverWid 为所选审批人(reportApplyFlow=1 时必填,其余传空)。
|
||||
func BuildLeaveReportForm(d *LeaveDetail, address, imageUrl, approverWid string) map[string]interface{} {
|
||||
if address == "" && len(d.ReportPlaces) > 0 {
|
||||
address = d.ReportPlaces[0].Address
|
||||
}
|
||||
form := map[string]interface{}{
|
||||
"id": d.ReportID,
|
||||
"needApproval": 1,
|
||||
"leaveType": d.LeaveCode,
|
||||
"reportLocation": address,
|
||||
"approverWid": approverWid,
|
||||
"reportApplyExplain": nil,
|
||||
"uaIsCpadaily": true,
|
||||
}
|
||||
if imageUrl != "" {
|
||||
form["reportAttach"] = []map[string]interface{}{
|
||||
{"type": 1, "url": imageUrl},
|
||||
}
|
||||
} else {
|
||||
form["reportAttach"] = []interface{}{}
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveReport 提交销假(bodyString 加密 + Cpdaily-Extension 签名,与签到提交同构)
|
||||
func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
bodyString := GetBodyString(form)
|
||||
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||
|
||||
submitData := map[string]interface{}{
|
||||
"lon": lon,
|
||||
"version": "first_v4",
|
||||
"calVersion": "first_v4",
|
||||
"deviceId": deviceId,
|
||||
"systemName": "android",
|
||||
"bodyString": bodyString,
|
||||
"lat": lat,
|
||||
"systemVersion": "15",
|
||||
"appVersion": "9.9.6",
|
||||
"model": "cpdaily",
|
||||
}
|
||||
submitData["userId"] = username
|
||||
submitData["sign"] = GetSubmitSign(submitData)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/report", submitData, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交销假请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
|
||||
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
|
||||
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
if code == 2210990000 {
|
||||
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
|
||||
} else {
|
||||
msg = "未知错误"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 请假申请功能(与销假同属 wec-counselor-leave-apps 模块):
|
||||
//
|
||||
// 请假类型列表:POST {host}/wec-counselor-leave-apps/leave/queryLeaveTypes
|
||||
// 类型详细配置:POST {host}/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg
|
||||
// 可请假检查: POST {host}/wec-counselor-leave-apps/leave/stu/checkOnceLeave
|
||||
// 审批人列表: POST {host}/wec-counselor-leave-apps/leave/stu/obtainApproveFlow
|
||||
// 提交请假: POST {host}/wec-counselor-leave-apps/leave/apply
|
||||
//
|
||||
// 注意:真实接口中 queryLeaveTypes 只返回基础信息(needApproval/enableAttach 等
|
||||
// 均为 null),权威开关全部来自 queryLeaveTypeCfg,故列表接口会把两者合并后返回。
|
||||
|
||||
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
|
||||
type LeaveType struct {
|
||||
Code int `json:"code"` // 类型编码(提交与查询都用它)
|
||||
Name string `json:"name"` // 类型名称
|
||||
// ProjectID 字符串型类型 ID(三易拓新学工5 等系统用 UUID 而非整数 code)
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
|
||||
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
|
||||
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
|
||||
ReasonWordLimit int `json:"reasonWordLimit"` // 请假原因最少字数
|
||||
AttachmentNumberLimit int `json:"attachmentNumberLimit"` // 附件数量上限
|
||||
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
|
||||
DisableTime int `json:"disableTime"` // 1=当前类型不可用
|
||||
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
|
||||
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
|
||||
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
|
||||
// —— 新套「附加信息」相关 ——
|
||||
RequireEvidence int `json:"requireEvidence"` // 1=必须上传证明材料(isRequiredEvidenceFile)
|
||||
EvidenceMin int `json:"evidenceMin"` // 证明材料最少张数
|
||||
SelfDefineFields []map[string]interface{} `json:"selfDefineFields"` // 自定义必填字段(如「当天是否返校」),提交时回填 selfDefineAnswers
|
||||
}
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
func CheckOnceLeave(cookieMap map[string]string, host string) error {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/checkOnceLeave", map[string]interface{}{}, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查请假资格失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "当前不可请假"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchLeaveTypes 获取请假类型列表(已合并 queryLeaveTypeCfg 的权威开关)。
|
||||
// 单个类型的 cfg 查询失败不阻塞整体列表(该类型保留基础信息,开关取保守默认值)。
|
||||
func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/queryLeaveTypes", map[string]interface{}{}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取请假类型失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
LeaveType []struct {
|
||||
Code interface{} `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MustOut interface{} `json:"mustOut"`
|
||||
ReasonWordLimit interface{} `json:"reasonWordLimit"`
|
||||
AttachmentNumberLimit interface{} `json:"attachmentNumberLimit"`
|
||||
} `json:"leaveType"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveType, 0, len(result.Datas.LeaveType))
|
||||
for _, lt := range result.Datas.LeaveType {
|
||||
codeNum, _ := strconv.Atoi(leaveAnyStr(lt.Code))
|
||||
item := LeaveType{
|
||||
Code: codeNum,
|
||||
Name: lt.Name,
|
||||
MustOut: leaveAnyInt(lt.MustOut),
|
||||
ReasonWordLimit: leaveAnyInt(lt.ReasonWordLimit),
|
||||
AttachmentNumberLimit: leaveAnyInt(lt.AttachmentNumberLimit),
|
||||
}
|
||||
if cfg, err := FetchLeaveTypeCfg(cookieMap, host, codeNum); err == nil {
|
||||
item.MustOut = cfg.MustOut
|
||||
item.NeedApproval = cfg.NeedApproval
|
||||
item.EnableAttach = cfg.EnableAttach
|
||||
item.ReasonWordLimit = cfg.ReasonWordLimit
|
||||
item.AttachmentNumberLimit = cfg.AttachmentNumberLimit
|
||||
item.ChooseAttendance = cfg.ChooseAttendance
|
||||
item.DisableTime = cfg.DisableTime
|
||||
item.LimitDay = cfg.LimitDay
|
||||
if cfg.Name != "" {
|
||||
item.Name = cfg.Name
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取请假类型配置失败,使用基础信息", "code", codeNum, "error", err)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// FetchLeaveTypeCfg 获取请假类型详细配置(needApproval/enableAttach 等权威开关)
|
||||
func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*LeaveType, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/queryLeaveTypeCfg", map[string]interface{}{
|
||||
"code": code,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取请假类型配置失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Code interface{} `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MustOut interface{} `json:"mustOut"`
|
||||
NeedApproval interface{} `json:"needApproval"`
|
||||
EnableAttach interface{} `json:"enableAttach"`
|
||||
ReasonWordLimit interface{} `json:"reasonWordLimit"`
|
||||
AttachmentNumberLimit interface{} `json:"attachmentNumberLimit"`
|
||||
ChooseAttendance interface{} `json:"chooseAttendance"`
|
||||
DisableTime interface{} `json:"disableTime"`
|
||||
LimitDay interface{} `json:"limitDay"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
codeVal, _ := result.Code.Int64()
|
||||
if codeVal != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
return &LeaveType{
|
||||
Code: leaveAnyInt(result.Datas.Code),
|
||||
Name: result.Datas.Name,
|
||||
MustOut: leaveAnyInt(result.Datas.MustOut),
|
||||
NeedApproval: leaveAnyInt(result.Datas.NeedApproval),
|
||||
EnableAttach: leaveAnyInt(result.Datas.EnableAttach),
|
||||
ReasonWordLimit: leaveAnyInt(result.Datas.ReasonWordLimit),
|
||||
AttachmentNumberLimit: leaveAnyInt(result.Datas.AttachmentNumberLimit),
|
||||
ChooseAttendance: leaveAnyInt(result.Datas.ChooseAttendance),
|
||||
DisableTime: leaveAnyInt(result.Datas.DisableTime),
|
||||
LimitDay: leaveAnyInt(result.Datas.LimitDay),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LeaveApplyParams 请假申请提交参数
|
||||
type LeaveApplyParams struct {
|
||||
LeaveType int // 类型编码
|
||||
StartTime string // yyyy-MM-dd HH:mm:ss
|
||||
EndTime string // yyyy-MM-dd HH:mm:ss
|
||||
LeaveReason string // 请假原因
|
||||
OutStatus bool // 是否离校
|
||||
UrgencyMobile string // 紧急联系人手机号(离校必填)
|
||||
Address string // 目的地(离校必填,如 xx省/xx市/xx区)
|
||||
DetailAddress string // 详细地址(离校必填)
|
||||
Longitude string // 定位经度
|
||||
Latitude string // 定位纬度
|
||||
LocationAddr string // 定位地址
|
||||
ImageURLs []string // 附件图片 URL 列表(可选,最多 4 张且不超学校上限)
|
||||
ApproverWid string // 审批人 userWid(needApproval=1 且学校配置了候选审批人时必填;候选为空可不选)
|
||||
ApproverId string // 审批人 userId
|
||||
ApproverName string // 审批人姓名
|
||||
CcUsers []LeaveApprover // 抄送人(非必填;候选取自审批人列表)
|
||||
}
|
||||
|
||||
// LeaveMaxAttachCount 附件数量上限:学校配置为准,但产品上限 4 张。
|
||||
// limit<=0(学校未配置)按 4;limit>4 收敛为 4;否则取学校值。
|
||||
func LeaveMaxAttachCount(schoolLimit int) int {
|
||||
if schoolLimit <= 0 || schoolLimit > 4 {
|
||||
return 4
|
||||
}
|
||||
return schoolLimit
|
||||
}
|
||||
|
||||
// BuildLeaveCcInfo 构造抄送人信息(ccInfo)。
|
||||
// 官方结构:{totalSize, teachers:[], students:[]},人员含 personType(0=老师 1=学生)。
|
||||
// 审批人接口不返回 personType,按 userId 是否纯数字推断(学号纯数字,教师工号常含字母)。
|
||||
func BuildLeaveCcInfo(users []LeaveApprover) map[string]interface{} {
|
||||
teachers := make([]interface{}, 0, len(users))
|
||||
students := make([]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.UserWid == "" {
|
||||
continue
|
||||
}
|
||||
personType := 1 // 默认学生
|
||||
if u.UserId == "" {
|
||||
personType = 0
|
||||
} else {
|
||||
for _, ch := range u.UserId {
|
||||
if ch < '0' || ch > '9' {
|
||||
personType = 0 // 含非数字字符 → 教师
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
person := map[string]interface{}{
|
||||
"userWid": u.UserWid,
|
||||
"userId": u.UserId,
|
||||
"userName": u.UserName,
|
||||
"name2Id": u.UserName + "/" + u.UserId,
|
||||
"personType": personType,
|
||||
"dept": nil,
|
||||
"sex": nil,
|
||||
"teacherName": u.UserName,
|
||||
}
|
||||
if personType == 0 {
|
||||
teachers = append(teachers, person)
|
||||
} else {
|
||||
students = append(students, person)
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"totalSize": len(teachers) + len(students),
|
||||
"teachers": teachers,
|
||||
"students": students,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildLeaveApplyForm 构造请假申请表单(/leave/apply 的明文 JSON body,
|
||||
// 与销假的 bodyString 加密不同:该接口只携带 Cpdaily-Extension 头)。
|
||||
func BuildLeaveApplyForm(p LeaveApplyParams, deviceId, systemVersion, appVersion string) map[string]interface{} {
|
||||
urgencyMobile := p.UrgencyMobile
|
||||
address := p.Address
|
||||
detailAddress := p.DetailAddress
|
||||
destination := ""
|
||||
if p.OutStatus {
|
||||
destination = address
|
||||
if detailAddress != "" {
|
||||
if destination != "" {
|
||||
destination += "/"
|
||||
}
|
||||
destination += detailAddress
|
||||
}
|
||||
} else {
|
||||
urgencyMobile = ""
|
||||
address = ""
|
||||
detailAddress = ""
|
||||
}
|
||||
|
||||
form := map[string]interface{}{
|
||||
"leaveType": p.LeaveType,
|
||||
"startTime": p.StartTime,
|
||||
"endTime": p.EndTime,
|
||||
"urgencyMobile": urgencyMobile,
|
||||
"leaveReason": p.LeaveReason,
|
||||
"isAttendance": nil,
|
||||
"outStatus": p.OutStatus,
|
||||
"address": address,
|
||||
"detailAddress": detailAddress,
|
||||
"destination": destination,
|
||||
"holidayAutoDefineVos": []interface{}{},
|
||||
"appVersion": appVersion,
|
||||
"deviceId": deviceId,
|
||||
"ccInfo": BuildLeaveCcInfo(p.CcUsers),
|
||||
"cheatType": nil,
|
||||
"source": 1,
|
||||
"location": map[string]interface{}{
|
||||
"deviceId": deviceId,
|
||||
"systemName": "Android",
|
||||
"systemVersion": systemVersion,
|
||||
"model": "cpdaily",
|
||||
"address": p.LocationAddr,
|
||||
"latitude": p.Latitude,
|
||||
"longitude": p.Longitude,
|
||||
},
|
||||
"locationType": 0,
|
||||
"disclaimers": "本人承诺填写的信息真实有效,并对本次提交请假申请的信息真实性负责。",
|
||||
"uaIsCpadaily": "true",
|
||||
"leaveApplyExplain": nil,
|
||||
}
|
||||
if p.ApproverWid != "" {
|
||||
form["approverWid"] = p.ApproverWid
|
||||
form["approverId"] = p.ApproverId
|
||||
form["approverName"] = p.ApproverName
|
||||
}
|
||||
if len(p.ImageURLs) > 0 {
|
||||
attachs := make([]map[string]interface{}, 0, len(p.ImageURLs))
|
||||
for _, u := range p.ImageURLs {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
attachs = append(attachs, map[string]interface{}{"type": 1, "url": u})
|
||||
}
|
||||
if len(attachs) > 0 {
|
||||
form["applyAttach"] = attachs
|
||||
} else {
|
||||
form["applyAttach"] = []interface{}{}
|
||||
}
|
||||
} else {
|
||||
form["applyAttach"] = []interface{}{}
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveApply 提交请假申请(明文 JSON body + Cpdaily-Extension 头)
|
||||
func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/apply", form, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交请假请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/apply 响应", "code", result.Code.String(), "message", result.Message)
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildLeaveExtendForm 构造旧套续假提交体(/saveExtend)。
|
||||
// 续假体 = 普通请假申请体 + leaveId + extendReason + extendHolidayAutoDefineVos(见官方 home.*.js)。
|
||||
// extendReason 复用 leaveReason;holiday 自动定义暂传空数组(与官方默认一致)。
|
||||
func BuildLeaveExtendForm(p LeaveApplyParams, deviceId, systemVersion, appVersion, leaveID string) map[string]interface{} {
|
||||
form := BuildLeaveApplyForm(p, deviceId, systemVersion, appVersion)
|
||||
form["leaveId"] = leaveID
|
||||
form["extendReason"] = p.LeaveReason
|
||||
form["extendHolidayAutoDefineVos"] = []interface{}{}
|
||||
|
||||
// 续假(/leave/saveExtend)定位参数与普通请假不同:官方提交体把坐标放在**顶层**
|
||||
// address/latitude/longitude(由前端 locationData 展开),而**不使用 location 子对象**。
|
||||
// 此前沿用请假的 location:{...},服务端报「请假定位参数错误」。
|
||||
// 实测官方 saveExtend 提交体字段:{...model, address, latitude, longitude, locationType, leaveId, extendReason, ...}
|
||||
delete(form, "location")
|
||||
form["address"] = p.LocationAddr
|
||||
form["latitude"] = p.Latitude
|
||||
form["longitude"] = p.Longitude
|
||||
form["locationType"] = 0
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveExtend 提交旧套续假(POST /saveExtend,明文 JSON + Cpdaily-Extension 头)。
|
||||
// 与 SubmitLeaveApply 同构,仅接口路径不同。
|
||||
func SubmitLeaveExtend(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/saveExtend", form, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交续假请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析续假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/saveExtend 响应", "code", result.Code.String(), "message", result.Message)
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mock 请假申请接口:queryLeaveTypes 返回基础信息(开关全 null,复刻真实返回),
|
||||
// queryLeaveTypeCfg 返回权威开关,checkOnceLeave 返回成功。
|
||||
func newLeaveApplyMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wec-counselor-leave-apps/leave/queryLeaveTypes":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"leaveType": []map[string]interface{}{
|
||||
{
|
||||
"id": "4443", "code": 3, "name": "外出申请(省内)", "mustOut": 1,
|
||||
"needApproval": nil, "enableAttach": nil, "reasonWordLimit": 10, "attachmentNumberLimit": 4,
|
||||
},
|
||||
{
|
||||
"id": "1225", "code": 1, "name": "事假", "mustOut": 0,
|
||||
"needApproval": nil, "enableAttach": nil, "reasonWordLimit": 10, "attachmentNumberLimit": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg":
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
code := leaveAnyInt(body["code"])
|
||||
// code=3:需要审批人 + 允许附件;code=1:均不需要(复刻实测差异)
|
||||
needApproval, enableAttach := 0, 0
|
||||
if code == 3 {
|
||||
needApproval, enableAttach = 1, 1
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"wid": "4443", "code": code, "name": "外出申请(省内)",
|
||||
"mustOut": 1, "needApproval": needApproval, "enableAttach": enableAttach,
|
||||
"reasonWordLimit": 10, "attachmentNumberLimit": 4, "chooseAttendance": 0,
|
||||
"disableTime": 0, "limitDay": 90,
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/checkOnceLeave":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": "0", "message": "SUCCESS", "datas": nil})
|
||||
case "/wec-counselor-leave-apps/leave/apply":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": "0", "message": "SUCCESS"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestFetchLeaveTypesMergesTypeCfg(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
list, err := FetchLeaveTypes(map[string]string{}, srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLeaveTypes 返回错误: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("期望 2 个请假类型,实际 %d 个", len(list))
|
||||
}
|
||||
// code=3:权威开关来自 cfg(needApproval=1 / enableAttach=1)
|
||||
var out LeaveType
|
||||
var personal LeaveType
|
||||
for _, lt := range list {
|
||||
if lt.Code == 3 {
|
||||
out = lt
|
||||
}
|
||||
if lt.Code == 1 {
|
||||
personal = lt
|
||||
}
|
||||
}
|
||||
if out.Name != "外出申请(省内)" || out.NeedApproval != 1 || out.EnableAttach != 1 || out.MustOut != 1 {
|
||||
t.Errorf("类型合并结果错误: %+v", out)
|
||||
}
|
||||
if out.ReasonWordLimit != 10 || out.LimitDay != 90 {
|
||||
t.Errorf("字数/天数限制错误: %+v", out)
|
||||
}
|
||||
if personal.NeedApproval != 0 || personal.EnableAttach != 0 {
|
||||
t.Errorf("事假不应需要审批/附件: %+v", personal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOnceLeave(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
if err := CheckOnceLeave(map[string]string{}, srv.URL); err != nil {
|
||||
t.Errorf("checkOnceLeave 应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLeaveApplyForm(t *testing.T) {
|
||||
// 离校:紧急联系人/目的地/详细地址均保留,destination 由地址拼接
|
||||
p := LeaveApplyParams{
|
||||
LeaveType: 3,
|
||||
StartTime: "2026-09-15 08:00:00",
|
||||
EndTime: "2026-09-16 20:00:00",
|
||||
LeaveReason: "回家处理家庭事务",
|
||||
OutStatus: true,
|
||||
UrgencyMobile: "13800000000",
|
||||
Address: "福建省/厦门市/思明区",
|
||||
DetailAddress: "某某路 1 号",
|
||||
Longitude: "118.1",
|
||||
Latitude: "24.6",
|
||||
LocationAddr: "福建省厦门市思明区",
|
||||
ImageURLs: []string{"https://obs.example.com/a.jpg", "https://obs.example.com/b.jpg"},
|
||||
ApproverWid: "93458825",
|
||||
ApproverId: "20220109",
|
||||
ApproverName: "罗亭",
|
||||
}
|
||||
form := BuildLeaveApplyForm(p, "device-1", "15", "9.9.6")
|
||||
|
||||
if form["leaveType"] != 3 || form["startTime"] != "2026-09-15 08:00:00" || form["outStatus"] != true {
|
||||
t.Errorf("基础字段错误: %+v", form)
|
||||
}
|
||||
if form["destination"] != "福建省/厦门市/思明区/某某路 1 号" {
|
||||
t.Errorf("destination 拼接错误: %v", form["destination"])
|
||||
}
|
||||
if form["approverWid"] != "93458825" || form["approverId"] != "20220109" || form["approverName"] != "罗亭" {
|
||||
t.Errorf("审批人字段错误: %v/%v/%v", form["approverWid"], form["approverId"], form["approverName"])
|
||||
}
|
||||
attach, ok := form["applyAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 2 || attach[0]["url"] != "https://obs.example.com/a.jpg" || attach[1]["url"] != "https://obs.example.com/b.jpg" {
|
||||
t.Errorf("多附件结构错误: %v", form["applyAttach"])
|
||||
}
|
||||
if attach[0]["type"] != 1 || attach[1]["type"] != 1 {
|
||||
t.Errorf("附件 type 应为 1: %v", attach)
|
||||
}
|
||||
loc, ok := form["location"].(map[string]interface{})
|
||||
if !ok || loc["longitude"] != "118.1" || loc["latitude"] != "24.6" || loc["address"] != "福建省厦门市思明区" {
|
||||
t.Errorf("location 结构错误: %v", form["location"])
|
||||
}
|
||||
|
||||
// 不离校:紧急联系人/地址字段清空、destination 为空、无审批人/无附件
|
||||
p2 := LeaveApplyParams{
|
||||
LeaveType: 1,
|
||||
StartTime: "2026-09-15 08:00:00",
|
||||
EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息",
|
||||
OutStatus: false,
|
||||
UrgencyMobile: "13800000000", // 故意传入,应被清空
|
||||
Address: "福建省/厦门市",
|
||||
DetailAddress: "某某路",
|
||||
Longitude: "118.1",
|
||||
Latitude: "24.6",
|
||||
LocationAddr: "校内",
|
||||
}
|
||||
form2 := BuildLeaveApplyForm(p2, "device-1", "15", "9.9.6")
|
||||
if form2["urgencyMobile"] != "" || form2["address"] != "" || form2["detailAddress"] != "" {
|
||||
t.Errorf("不离校时地址字段应清空: %v/%v/%v", form2["urgencyMobile"], form2["address"], form2["detailAddress"])
|
||||
}
|
||||
if form2["destination"] != "" {
|
||||
t.Errorf("不离校时 destination 应为空: %v", form2["destination"])
|
||||
}
|
||||
if _, ok := form2["approverWid"]; ok {
|
||||
t.Errorf("未选审批人时不应携带 approverWid")
|
||||
}
|
||||
if attach, ok := form2["applyAttach"].([]interface{}); !ok || len(attach) != 0 {
|
||||
t.Errorf("无附件时 applyAttach 应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
// 无抄送人:ccInfo 仍必须存在且为空结构
|
||||
cc, ok := form2["ccInfo"].(map[string]interface{})
|
||||
if !ok || cc["totalSize"] != 0 {
|
||||
t.Errorf("无抄送人时 ccInfo 应为空结构: %v", form2["ccInfo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLeaveCcInfo(t *testing.T) {
|
||||
// 学生(纯数字 userId)+ 教师(含字母 userId)分组
|
||||
users := []LeaveApprover{
|
||||
{UserWid: "92274797", UserId: "02727", UserName: "王欣然"}, // 纯数字 → students
|
||||
{UserWid: "915560902", UserId: "kjxy0003", UserName: "杨瑚"}, // 含字母 → teachers
|
||||
}
|
||||
cc := BuildLeaveCcInfo(users)
|
||||
if cc["totalSize"] != 2 {
|
||||
t.Errorf("totalSize 应为 2,实际 %v", cc["totalSize"])
|
||||
}
|
||||
students, _ := cc["students"].([]interface{})
|
||||
teachers, _ := cc["teachers"].([]interface{})
|
||||
if len(students) != 1 || len(teachers) != 1 {
|
||||
t.Fatalf("分组错误: students=%d teachers=%d", len(students), len(teachers))
|
||||
}
|
||||
st, _ := students[0].(map[string]interface{})
|
||||
if st["userWid"] != "92274797" || st["name2Id"] != "王欣然/02727" || st["personType"] != 1 {
|
||||
t.Errorf("学生抄送人字段错误: %v", st)
|
||||
}
|
||||
tc, _ := teachers[0].(map[string]interface{})
|
||||
if tc["userWid"] != "915560902" || tc["personType"] != 0 {
|
||||
t.Errorf("教师抄送人字段错误: %v", tc)
|
||||
}
|
||||
|
||||
// 空列表:结构完整但 totalSize=0
|
||||
empty := BuildLeaveCcInfo(nil)
|
||||
if empty["totalSize"] != 0 {
|
||||
t.Errorf("空抄送人 totalSize 应为 0: %v", empty["totalSize"])
|
||||
}
|
||||
if arr, ok := empty["teachers"].([]interface{}); !ok || len(arr) != 0 {
|
||||
t.Errorf("空抄送人 teachers 应为空数组: %v", empty["teachers"])
|
||||
}
|
||||
|
||||
// 提交表单携带抄送人
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
CcUsers: users,
|
||||
}, "device-1", "15", "9.9.6")
|
||||
fcc, _ := form["ccInfo"].(map[string]interface{})
|
||||
if fcc == nil || fcc["totalSize"] != 2 {
|
||||
t.Errorf("表单 ccInfo 应包含 2 名抄送人: %v", form["ccInfo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitLeaveApply(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
}, "device-1", "15", "9.9.6")
|
||||
|
||||
if err := SubmitLeaveApply(map[string]string{}, srv.URL, form, "118.1", "24.6", "device-1", "20251960205"); err != nil {
|
||||
t.Errorf("SubmitLeaveApply 应成功: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveMaxAttachCount 附件数量上限:学校未配置/超 4 张收敛为 4,否则取学校值
|
||||
func TestLeaveMaxAttachCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
schoolLimit int
|
||||
want int
|
||||
}{
|
||||
{0, 4}, // 未配置
|
||||
{-1, 4}, // 异常值
|
||||
{2, 2},
|
||||
{4, 4},
|
||||
{9, 4}, // 超产品上限
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LeaveMaxAttachCount(c.schoolLimit); got != c.want {
|
||||
t.Errorf("LeaveMaxAttachCount(%d) = %d, want %d", c.schoolLimit, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveApplyFormEmptyAttachURLs 多图字段中的空串应被剔除,全空时仍输出空数组
|
||||
func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
ImageURLs: []string{"", "https://obs.example.com/only.jpg", ""},
|
||||
}, "device-1", "15", "9.9.6")
|
||||
attach, ok := form["applyAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/only.jpg" {
|
||||
t.Errorf("空串应被剔除: %v", form["applyAttach"])
|
||||
}
|
||||
|
||||
form2 := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
ImageURLs: []string{"", ""},
|
||||
}, "device-1", "15", "9.9.6")
|
||||
if attach2, ok := form2["applyAttach"].([]interface{}); !ok || len(attach2) != 0 {
|
||||
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
}
|
||||
|
||||
// 续假(旧套 saveExtend)提交体:坐标必须放顶层 latitude/longitude/address,
|
||||
// 且不得携带请假用的 location 子对象(否则服务端报「请假定位参数错误」)。
|
||||
func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
|
||||
p := LeaveApplyParams{
|
||||
LeaveType: 12, StartTime: "2026-10-08 22:30", EndTime: "2026-10-10 22:30",
|
||||
LeaveReason: "续假三天", OutStatus: false,
|
||||
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
|
||||
}
|
||||
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
|
||||
|
||||
if _, has := form["location"]; has {
|
||||
t.Fatal("续假提交体不应包含 location 子对象")
|
||||
}
|
||||
if form["latitude"] != "24.82989733" || form["longitude"] != "102.85939585" {
|
||||
t.Fatalf("坐标应在顶层 latitude/longitude,实际 lat=%v lon=%v", form["latitude"], form["longitude"])
|
||||
}
|
||||
if form["address"] != "云南省昆明市呈贡区洋浦路" {
|
||||
t.Fatalf("address 应为定位地址,实际 %v", form["address"])
|
||||
}
|
||||
if form["leaveId"] != "3d917d58abcd" || form["extendReason"] != "续假三天" {
|
||||
t.Fatalf("leaveId/extendReason 错误: %v / %v", form["leaveId"], form["extendReason"])
|
||||
}
|
||||
if _, has := form["extendHolidayAutoDefineVos"]; !has {
|
||||
t.Fatal("应携带 extendHolidayAutoDefineVos")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLeaveNewObsKeyOf 验证 obsKey 推导(与官方前端 AA 函数一致)
|
||||
func TestLeaveNewObsKeyOf(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"普通路径", "https://counselor-cos-wecres.campusphere.net/counselor/leave/1020385267004784/attachment/abc.jpeg?sign=xxx", "/counselor/leave/1020385267004784/attachment/abc.jpeg"},
|
||||
{"newBucket形态", "https://cdn.example.com/foo/newBucket/leave/abc.png", "newBucket/leave/abc.png"},
|
||||
{"空URL", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := LeaveNewObsKeyOf(c.url); got != c.want {
|
||||
t.Errorf("LeaveNewObsKeyOf(%q) = %q, want %q", c.url, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveAppFlavorFromApps 验证应用列表 → 套件判定
|
||||
func TestLeaveNewCanReport(t *testing.T) {
|
||||
cases := []struct {
|
||||
status, reportMode string
|
||||
want bool
|
||||
}{
|
||||
{"3", "1", true},
|
||||
{"4", "1", true},
|
||||
{"3", "2", true},
|
||||
{"4", "2", true},
|
||||
{"3", "0", false},
|
||||
{"4", "3", false},
|
||||
{"2", "1", false},
|
||||
{"7", "1", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := leaveNewCanReport(LeaveNewItem{Status: c.status, ReportMode: c.reportMode})
|
||||
if got != c.want {
|
||||
t.Errorf("canReport(status=%s, reportMode=%s) = %v, want %v", c.status, c.reportMode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewStatusText 验证状态文案映射完整
|
||||
func TestLeaveNewStatusText(t *testing.T) {
|
||||
for _, code := range []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"} {
|
||||
if LeaveNewStatusText[code] == "" {
|
||||
t.Errorf("status %s 缺少文案映射", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewReportForm 验证销假表单结构(含确认人与附件)
|
||||
func TestBuildLeaveNewReportForm(t *testing.T) {
|
||||
approver := &LeaveNewApprover{UserWid: "92271929", UserId: "2022110006", UserName: "张文烨", RoleCode: "1003", RoleName: "辅导员", DeptName: "智能制造学院"}
|
||||
form := BuildLeaveNewReportForm(LeaveNewReportParams{
|
||||
LeaveID: "e1529da9d4c94341b0b1f7f2278eb2ab",
|
||||
Address: "泰州学院",
|
||||
Longitude: "119.948267",
|
||||
Latitude: "32.474102",
|
||||
Approver: approver,
|
||||
})
|
||||
if form["leaveId"] != "e1529da9d4c94341b0b1f7f2278eb2ab" {
|
||||
t.Errorf("leaveId 缺失: %v", form["leaveId"])
|
||||
}
|
||||
loc, ok := form["location"].(map[string]interface{})
|
||||
if !ok || loc["address"] != "泰州学院" {
|
||||
t.Errorf("location 结构错误: %v", form["location"])
|
||||
}
|
||||
if form["approverWid"] != "92271929" || form["approverRoleCode"] != "1003" {
|
||||
t.Errorf("确认人字段错误: approverWid=%v approverRoleCode=%v", form["approverWid"], form["approverRoleCode"])
|
||||
}
|
||||
if attachs, ok := form["attachs"].([]interface{}); !ok || len(attachs) != 0 {
|
||||
t.Errorf("无附件时 attachs 应为空数组: %v", form["attachs"])
|
||||
}
|
||||
|
||||
form2 := BuildLeaveNewReportForm(LeaveNewReportParams{
|
||||
LeaveID: "x", Address: "a", Longitude: "1", Latitude: "2",
|
||||
ImageURL: "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
|
||||
})
|
||||
attachs2 := form2["attachs"].([]map[string]interface{})
|
||||
if len(attachs2) != 1 || attachs2[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
|
||||
t.Errorf("附件结构错误: %v", attachs2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewApplyForm 验证请假表单结构(离校/不离校两分支 + 抄送人)
|
||||
func TestBuildLeaveNewApplyForm(t *testing.T) {
|
||||
approver := &LeaveNewApprover{UserWid: "92271929", UserId: "2022110006", UserName: "张文烨"}
|
||||
form := BuildLeaveNewApplyForm(LeaveNewApplyParams{
|
||||
TypeWid: 8214,
|
||||
BeginTime: "2026-09-16 08:00",
|
||||
EndTime: "2026-09-16 18:00",
|
||||
LeaveReason: "测试原因",
|
||||
LocationAddr: "泰州学院",
|
||||
Longitude: "119.9",
|
||||
Latitude: "32.4",
|
||||
IsLeaveOut: 1,
|
||||
Destination: "江苏省/南京市/玄武区",
|
||||
Detail: "某地",
|
||||
EmergencyName: "张三",
|
||||
EmergencyMobile: "13800000000",
|
||||
Approver: approver,
|
||||
CcUsers: []LeaveNewApprover{{UserWid: "1", UserId: "s1", UserName: "李四", RoleCode: "1003"}},
|
||||
ImageURLs: []string{
|
||||
"https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
|
||||
"https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2",
|
||||
},
|
||||
})
|
||||
if form["typeWid"] != 8214 {
|
||||
t.Errorf("typeWid 错误: %v", form["typeWid"])
|
||||
}
|
||||
attachs := form["attachs"].([]map[string]interface{})
|
||||
if len(attachs) != 2 {
|
||||
t.Fatalf("多附件应为 2 张: %v", attachs)
|
||||
}
|
||||
if attachs[0]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1" ||
|
||||
attachs[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
|
||||
t.Errorf("第 1 张附件结构错误: %v", attachs[0])
|
||||
}
|
||||
if attachs[1]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2" ||
|
||||
attachs[1]["obsKey"] != "/counselor/leave/1/attachment/b.png" {
|
||||
t.Errorf("第 2 张附件结构错误: %v", attachs[1])
|
||||
}
|
||||
if attachs[0]["attachType"] != 1 || attachs[1]["attachType"] != 1 {
|
||||
t.Errorf("attachType 应为 1: %v", attachs)
|
||||
}
|
||||
lo := form["leaveOutInfo"].(map[string]interface{})
|
||||
if lo["isLeaveOut"] != 1 || lo["destination"] != "江苏省/南京市/玄武区" || lo["emergencyConcat"] != "张三" {
|
||||
t.Errorf("leaveOutInfo 错误: %v", lo)
|
||||
}
|
||||
cc := form["ccObjects"].([]map[string]interface{})
|
||||
if len(cc) != 1 || cc[0]["userWid"] != "1" || cc[0]["source"] != "2" {
|
||||
t.Errorf("ccObjects 错误: %v", cc)
|
||||
}
|
||||
|
||||
// 不离校分支:离校字段应清空
|
||||
form2 := BuildLeaveNewApplyForm(LeaveNewApplyParams{TypeWid: 8216, IsLeaveOut: 0, Destination: "不该出现", EmergencyMobile: "138"})
|
||||
lo2 := form2["leaveOutInfo"].(map[string]interface{})
|
||||
if lo2["destination"] != "" || lo2["emergencyMobile"] != "" {
|
||||
t.Errorf("不离校时离校字段应清空: %v", lo2)
|
||||
}
|
||||
if attachs2, ok := form2["attachs"].([]map[string]interface{}); !ok || len(attachs2) != 0 {
|
||||
t.Errorf("无附件时 attachs 应为空数组: %v", form2["attachs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewResultErr 验证响应码解析(code 为字符串 "0")
|
||||
func TestLeaveNewResultErr(t *testing.T) {
|
||||
if _, err := leaveNewResultErr([]byte(`{"code":"0","message":"SUCCESS","datas":{}}`), "测试"); err != nil {
|
||||
t.Errorf("code=0 不应报错: %v", err)
|
||||
}
|
||||
if _, err := leaveNewResultErr([]byte(`{"code":"-1","message":"参数错误"}`), "测试"); err == nil || err.Error() != "参数错误" {
|
||||
t.Errorf("非 0 应返回 message: %v", err)
|
||||
}
|
||||
if _, err := leaveNewResultErr([]byte(`<html>404</html>`), "测试"); err == nil {
|
||||
t.Error("非 JSON 应报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
|
||||
//
|
||||
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
|
||||
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
|
||||
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
|
||||
//
|
||||
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
|
||||
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
|
||||
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
|
||||
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
|
||||
//
|
||||
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
|
||||
|
||||
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
|
||||
|
||||
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
|
||||
type LeaveReturnPlace struct {
|
||||
Wid int `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Scope float64 `json:"scope"` // 允许半径(米)
|
||||
}
|
||||
|
||||
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
|
||||
type LeaveReturnTask struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
|
||||
StartTime string `json:"startTime"` // 假期开始
|
||||
EndTime string `json:"endTime"` // 假期结束
|
||||
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
|
||||
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
|
||||
}
|
||||
|
||||
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
|
||||
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
resp, err := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap)).
|
||||
SetBody(body).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
|
||||
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Datas struct {
|
||||
Rows []LeaveReturnPlace `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
|
||||
}
|
||||
return result.Datas.Rows, nil
|
||||
}
|
||||
|
||||
// leaveReturnListRow queryStuRegisterList 行
|
||||
type leaveReturnListRow struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
Status string `json:"status"`
|
||||
LeaveName string `json:"leaveName"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
ReturnSignType string `json:"returnSignType"`
|
||||
Disclaimer string `json:"disclaimer"`
|
||||
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
|
||||
}
|
||||
|
||||
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
|
||||
// hasHistory=false 表示只取当前(未归档)任务。
|
||||
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
|
||||
"hasHistory": false,
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Datas struct {
|
||||
Rows []leaveReturnListRow `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
|
||||
}
|
||||
return leaveReturnTasksFromRows(result.Datas.Rows), nil
|
||||
}
|
||||
|
||||
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
|
||||
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
|
||||
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
|
||||
out := make([]LeaveReturnTask, 0)
|
||||
for _, r := range rows {
|
||||
if r.Status != "1" {
|
||||
continue
|
||||
}
|
||||
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
|
||||
if ri != "" && ri != "null" {
|
||||
continue // 已登记返校
|
||||
}
|
||||
signType := 1
|
||||
if strings.TrimSpace(r.ReturnSignType) == "2" {
|
||||
signType = 2
|
||||
}
|
||||
out = append(out, LeaveReturnTask{
|
||||
TaskWid: r.TaskWid,
|
||||
StuWid: r.StuWid,
|
||||
LeaveName: r.LeaveName,
|
||||
StartTime: r.StartTime,
|
||||
EndTime: r.EndTime,
|
||||
SignType: signType,
|
||||
Disclaimer: r.Disclaimer,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LeaveReturnSignWidOf 从返校登记任务派生 SignWid("taskWid:stuWid",供统一任务体系存取)
|
||||
func LeaveReturnSignWidOf(taskWid, stuWid int) string {
|
||||
return fmt.Sprintf("%d:%d", taskWid, stuWid)
|
||||
}
|
||||
|
||||
// ParseLeaveReturnSignWid 解析 SignWid → (taskWid, stuWid)
|
||||
func ParseLeaveReturnSignWid(signWid string) (int, int, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("返校登记标识格式错误: %q", signWid)
|
||||
}
|
||||
taskWid, err1 := strconv.Atoi(parts[0])
|
||||
stuWid, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("返校登记标识解析失败: %q", signWid)
|
||||
}
|
||||
return taskWid, stuWid, nil
|
||||
}
|
||||
|
||||
// FetchReturnRegisterTasks 返校登记待办 → 统一任务项(供打卡任务标题来源)。
|
||||
// TaskName=假期名;SignWid="taskWid:stuWid"。
|
||||
func FetchReturnRegisterTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
pending, err := FetchLeaveReturnPending(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TodayTaskInfo, 0, len(pending))
|
||||
for _, t := range pending {
|
||||
out = append(out, TodayTaskInfo{
|
||||
TaskName: t.LeaveName,
|
||||
SignWid: LeaveReturnSignWidOf(t.TaskWid, t.StuWid),
|
||||
IsSigned: false,
|
||||
TaskType: string(TaskTypeReturnRegister),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
|
||||
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
|
||||
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
|
||||
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
|
||||
payload := map[string]interface{}{
|
||||
"taskWid": taskWid,
|
||||
"stuWid": stuWid,
|
||||
"returnAddress": address,
|
||||
"returnLongitude": longitude,
|
||||
"returnLatitude": latitude,
|
||||
"returnSignType": fmt.Sprintf("%d", signType),
|
||||
}
|
||||
if signType == 2 {
|
||||
payload["outsideSignReason"] = outsideReason
|
||||
}
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交返校登记失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
|
||||
}
|
||||
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
|
||||
if result.Code != "0" {
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
msg = "返校登记失败,请稍后重试"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveReturnSignWidRoundTrip 任务标识 "taskWid:stuWid" 编解码
|
||||
func TestLeaveReturnSignWidRoundTrip(t *testing.T) {
|
||||
s := LeaveReturnSignWidOf(25, 471179)
|
||||
if s != "25:471179" {
|
||||
t.Fatalf("LeaveReturnSignWidOf = %q", s)
|
||||
}
|
||||
tw, sw, err := ParseLeaveReturnSignWid(s)
|
||||
if err != nil || tw != 25 || sw != 471179 {
|
||||
t.Fatalf("ParseLeaveReturnSignWid(%q) = %d,%d,%v", s, tw, sw, err)
|
||||
}
|
||||
if _, _, err := ParseLeaveReturnSignWid("bad"); err == nil {
|
||||
t.Fatalf("非法标识应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
|
||||
func TestLeaveReturnTasksFromRows(t *testing.T) {
|
||||
rows := []leaveReturnListRow{
|
||||
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
|
||||
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
|
||||
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
|
||||
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
|
||||
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
|
||||
ReturnSignType: "2"}, // 待办,范围外
|
||||
}
|
||||
got := leaveReturnTasksFromRows(rows)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
|
||||
t.Errorf("第 1 条待办不符: %+v", got[0])
|
||||
}
|
||||
if got[1].TaskWid != 22 || got[1].SignType != 2 {
|
||||
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mock 销假模块接口:listOther 返回两行(一行待销假、一行已完成),
|
||||
// detailOther 返回规定销假位置(reportPlaces)与开关(reportApplyFlow/enableAttach)。
|
||||
// leaveType.code 故意用数字,复刻真实接口(此前声明为 string 导致解析失败)。
|
||||
func newLeaveMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wec-counselor-leave-apps/leave/stu/listOther":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"rows": []map[string]interface{}{
|
||||
{
|
||||
"id": "301",
|
||||
"leaveType": "事假",
|
||||
"leaveReason": "回家",
|
||||
"createTime": "2026-09-13 10:00",
|
||||
"actEndTime": "2026-09-14 08:00",
|
||||
"status": "8",
|
||||
},
|
||||
{
|
||||
"id": "302",
|
||||
"leaveType": "病假",
|
||||
"status": "4", // 已完成,应被过滤
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/detailOther":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"detail": map[string]interface{}{
|
||||
"id": "301",
|
||||
"startDate": "2026-09-13 10:00",
|
||||
"endDate": "2026-09-14 08:00",
|
||||
},
|
||||
"leaveType": map[string]interface{}{
|
||||
"code": 1, // 数字,复刻真实返回
|
||||
"name": "事假",
|
||||
"reportApplyFlow": 1,
|
||||
"enableAttach": 1,
|
||||
},
|
||||
"reportPlaces": []map[string]interface{}{
|
||||
{
|
||||
"wid": "1184",
|
||||
"address": "云南大学东二院",
|
||||
"longitude": 102.70567818032221,
|
||||
"latitude": "25.059769487255817",
|
||||
"radius": "360",
|
||||
"counName": "ampadmin",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/obtainApproveFlow":
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
// 时间必须带秒,否则真实服务端返回"日期时间格式错误"
|
||||
start, _ := body["startTime"].(string)
|
||||
end, _ := body["endTime"].(string)
|
||||
if len(start) != 19 || len(end) != 19 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "2210990000", "message": "日期时间格式错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"approvers": []map[string]interface{}{
|
||||
{"userWid": "93458825", "userId": "20220109", "userName": "罗亭"},
|
||||
{"userWid": "1246664953", "userId": "12024206007", "userName": "曾伟"},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestFetchLeaveListFiltersCompletedAndParsesReportPlaces(t *testing.T) {
|
||||
srv := newLeaveMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
items, err := FetchLeaveList(map[string]string{"MOD_AUTH_CAS": "test"}, srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLeaveList 返回错误: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("期望过滤后剩 1 条待销假记录,实际 %d 条: %+v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item.ID != "301" || item.LeaveType != "事假" || item.Reason != "回家" {
|
||||
t.Errorf("列表字段解析错误: %+v", item)
|
||||
}
|
||||
if len(item.ReportPlaces) != 1 {
|
||||
t.Fatalf("期望解析出 1 个规定销假位置,实际 %d 个", len(item.ReportPlaces))
|
||||
}
|
||||
p := item.ReportPlaces[0]
|
||||
// 数字型经纬度应转为纯文本(避免科学计数法)
|
||||
if p.Longitude != "102.70567818032221" || p.Latitude != "25.059769487255817" || p.Address != "云南大学东二院" || p.Radius != "360" {
|
||||
t.Errorf("规定销假位置解析错误: %+v", p)
|
||||
}
|
||||
if !item.ReportApplyFlow || !item.EnableAttach {
|
||||
t.Errorf("开关解析错误: reportApplyFlow=%v enableAttach=%v", item.ReportApplyFlow, item.EnableAttach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLeaveDetailParsesSubmitFields(t *testing.T) {
|
||||
srv := newLeaveMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
d, err := FetchLeaveDetail(map[string]string{}, srv.URL, "301")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLeaveDetail 返回错误: %v", err)
|
||||
}
|
||||
if d.ReportID != "301" {
|
||||
t.Errorf("ReportID = %q, 期望 301", d.ReportID)
|
||||
}
|
||||
// 数字型 code 应转为纯文本 + 数字双形式
|
||||
if d.LeaveCode != "1" {
|
||||
t.Errorf("LeaveCode = %q, 期望 1", d.LeaveCode)
|
||||
}
|
||||
if d.LeaveCodeNum != 1 {
|
||||
t.Errorf("LeaveCodeNum = %d, 期望 1", d.LeaveCodeNum)
|
||||
}
|
||||
if d.LeaveType != "事假" {
|
||||
t.Errorf("LeaveType = %q, 期望 事假", d.LeaveType)
|
||||
}
|
||||
if !d.ReportApplyFlow || !d.EnableAttach {
|
||||
t.Errorf("开关解析错误: reportApplyFlow=%v enableAttach=%v", d.ReportApplyFlow, d.EnableAttach)
|
||||
}
|
||||
if d.StartTime != "2026-09-13 10:00" || d.EndTime != "2026-09-14 08:00" {
|
||||
t.Errorf("起止时间解析错误: %q ~ %q", d.StartTime, d.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReportApprovers(t *testing.T) {
|
||||
srv := newLeaveMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
list, err := FetchReportApprovers(map[string]string{}, srv.URL, 1, "2026-09-13 10:00", "2026-09-14 08:00")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReportApprovers 返回错误: %v", err)
|
||||
}
|
||||
if len(list) != 2 || list[0].UserName != "罗亭" || list[0].UserWid != "93458825" {
|
||||
t.Errorf("审批人解析错误: %+v", list)
|
||||
}
|
||||
|
||||
// 秒级时间:入参已含秒时原样透传
|
||||
if got := leaveTimeWithSeconds("2026-09-13 10:00:30"); got != "2026-09-13 10:00:30" {
|
||||
t.Errorf("已含秒时间不应改动,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLeaveReportForm(t *testing.T) {
|
||||
d := &LeaveDetail{
|
||||
ReportID: "301",
|
||||
LeaveCode: "1",
|
||||
ReportPlaces: []LeaveReportPlace{
|
||||
{Address: "规定位置地址", Longitude: "118.1", Latitude: "24.6"},
|
||||
},
|
||||
}
|
||||
|
||||
// 无用户地址:兜底使用第一个规定销假位置地址;无图片:reportAttach 为空数组
|
||||
form := BuildLeaveReportForm(d, "", "", "")
|
||||
if form["reportLocation"] != "规定位置地址" {
|
||||
t.Errorf("reportLocation 应兜底规定位置地址,实际 %v", form["reportLocation"])
|
||||
}
|
||||
if attach, ok := form["reportAttach"].([]interface{}); !ok || len(attach) != 0 {
|
||||
t.Errorf("无图片时 reportAttach 应为空数组,实际 %v", form["reportAttach"])
|
||||
}
|
||||
if form["approverWid"] != "" {
|
||||
t.Errorf("未选审批人时 approverWid 应为空字符串,实际 %v", form["approverWid"])
|
||||
}
|
||||
if form["id"] != "301" || form["leaveType"] != "1" {
|
||||
t.Errorf("id/leaveType 字段错误: %v / %v", form["id"], form["leaveType"])
|
||||
}
|
||||
|
||||
// 用户地址优先 + 有图片 + 选择了审批人
|
||||
form2 := BuildLeaveReportForm(d, "用户填写地址", "https://obs.example.com/a.jpg", "93458825")
|
||||
if form2["reportLocation"] != "用户填写地址" {
|
||||
t.Errorf("用户填写的地址应优先,实际 %v", form2["reportLocation"])
|
||||
}
|
||||
if form2["approverWid"] != "93458825" {
|
||||
t.Errorf("应携带所选审批人,实际 %v", form2["approverWid"])
|
||||
}
|
||||
attach, ok := form2["reportAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 1 || attach[0]["type"] != 1 || attach[0]["url"] != "https://obs.example.com/a.jpg" {
|
||||
t.Errorf("有图片时 reportAttach 结构错误: %v", form2["reportAttach"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 金智 xsfw 学工平台「学生请假」应用(xsfw) ==========
|
||||
//
|
||||
// 适用学校:云南警官学院(xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp)等使用
|
||||
// 金智 wisedu xsfw/EMAP 学工平台的学校。辅导猫侧请假(wec-counselor-leave-*/)
|
||||
// 在这些学校为 HTTP 420「产品已到期」,与该应用无关。
|
||||
//
|
||||
// 链路(只读部分已实测跑通):
|
||||
//
|
||||
// CAS 登录 https://authserver.<domain>/authserver/login?service=... → MOD_AUTH_CAS
|
||||
// 应用首页 {host}/xsfwdt/sys/xsqjapp/*default/index.do → WIS_CONFIG{APPID,APPNAME,TITLE:"学生请假"}
|
||||
// 请假列表 POST {host}/xsfwdt/sys/xsqjapp/modules/wdqj/wdqjbg.do → requestParamStr=<JSON>
|
||||
// 请假详情 GET {host}/xsfwdt/sys/xsqjapp/modules/wdqj/qjxqbd.do?*json=1&SQBH=<申请编号>
|
||||
// 请假类型 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getLeaveType.do → {"XSBH":"<学号>"}
|
||||
// 审批路由 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getCaseByType.do → {"QJLX":..,"QJTS":..}
|
||||
// 配额 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedRss.do
|
||||
// 已请天数 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedDays.do
|
||||
//
|
||||
// 注意:参数名必须是 requestParamStr(不是 requestParams,后者返回 HTML 错误页)。
|
||||
|
||||
// XsfwSchoolIDs 使用金智 xsfw 学工平台独立请假应用的学校 ID 集合
|
||||
var XsfwSchoolIDs = map[string]bool{
|
||||
"2970d544-2917-44e6-85db-f3245d6e3b1d": true, // 云南警官学院
|
||||
}
|
||||
|
||||
// IsXsfwSchool 判断学校是否使用金智 xsfw 学工请假应用
|
||||
func IsXsfwSchool(schoolID string) bool { return XsfwSchoolIDs[schoolID] }
|
||||
|
||||
// xsfwAppRoot 返回该校「学生请假」应用根路径(含协议与端口)
|
||||
func xsfwAppRoot(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
|
||||
// 该校 https 会被 TLS 重置(校内白名单),必须用 http
|
||||
return "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// XsfwSession 学工平台会话(依赖 CAS 会话 cookie)
|
||||
type XsfwSession struct {
|
||||
cookieMap map[string]string
|
||||
root string
|
||||
client *resty.Client
|
||||
}
|
||||
|
||||
// NewXsfwSession 用已有 cookie(含 MOD_AUTH_CAS/_WEU)构造会话
|
||||
func NewXsfwSession(schoolID string, cookieMap map[string]string) (*XsfwSession, error) {
|
||||
root := xsfwAppRoot(schoolID)
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
|
||||
}
|
||||
client := resty.New().SetTimeout(30*time.Second).
|
||||
// 该平台对 UA 敏感:空/默认 UA 会被重定向到 CAS 登录页(返回 HTML)
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
|
||||
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
// 会话靠 cookie 传递:统一挂到 client 上,避免每个请求手写时漏带
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap))
|
||||
return &XsfwSession{
|
||||
cookieMap: cookieMap,
|
||||
root: root,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *XsfwSession) cookieStr() string {
|
||||
return cookiesToStr(s.cookieMap)
|
||||
}
|
||||
|
||||
// xsfwUnmarshal 解析学工平台响应。
|
||||
// 该校响应可能带**多重** UTF-8 BOM(实测 \xEF\xBB\xBF 重复两次),
|
||||
// 标准 json.Unmarshal 会报 "Unexpected UTF-8 BOM" / "invalid character 'ï'",
|
||||
// 故循环剥离所有前导 BOM 后再解析。
|
||||
func xsfwUnmarshal(body []byte, v interface{}) error {
|
||||
b := body
|
||||
// 反复剔除前导空白与 BOM,直到不再变化(两者可能交替出现)
|
||||
for {
|
||||
prev := len(b)
|
||||
b = bytes.TrimLeft(b, " \t\r\n")
|
||||
b = bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF})
|
||||
if len(b) == prev {
|
||||
break
|
||||
}
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// callModule 以 requestParamStr 形式调用应用模块接口
|
||||
func (s *XsfwSession) callModule(ctx context.Context, module, action string, params map[string]interface{}) ([]byte, error) {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.client.R().SetContext(ctx).
|
||||
SetHeader("Cookie", s.cookieStr()).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", s.root+"/*default/index.do").
|
||||
SetBody("requestParamStr=" + url.QueryEscape(string(raw))).
|
||||
Post(fmt.Sprintf("%s/modules/%s.do", s.root, module))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body(), nil
|
||||
}
|
||||
|
||||
// XsfwFetchLeaveList 查询我的请假列表(wdqjbg,只读)
|
||||
func (s *XsfwSession) XsfwFetchLeaveList(ctx context.Context, page, size int) ([]map[string]interface{}, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
body, err := s.callModule(ctx, "wdqj/wdqjbg", "", map[string]interface{}{
|
||||
"pageNumber": page,
|
||||
"pageSize": size,
|
||||
"querySetting": "[]",
|
||||
"action": "QUERY",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var result struct {
|
||||
TotalSize int `json:"totalSize"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
}
|
||||
if err := xsfwUnmarshal(body, &result); err != nil {
|
||||
return nil, 0, fmt.Errorf("解析请假列表失败: %w", err)
|
||||
}
|
||||
return result.Rows, result.TotalSize, nil
|
||||
}
|
||||
|
||||
// XsfwFetchLeaveTypes 查询可请的请假类型(getLeaveType,必须传学号)
|
||||
func (s *XsfwSession) XsfwFetchLeaveTypes(ctx context.Context, studentID string) ([]map[string]interface{}, error) {
|
||||
body, err := s.callModule(ctx, "leaveApply/getLeaveType", "", map[string]interface{}{
|
||||
"XSBH": studentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
}
|
||||
if err := xsfwUnmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w", err)
|
||||
}
|
||||
return result.Rows, nil
|
||||
}
|
||||
|
||||
// XsfwFetchAppliedDays 查询已请天数与上限(getAppliedDays)
|
||||
func (s *XsfwSession) XsfwFetchAppliedDays(ctx context.Context) (map[string]interface{}, error) {
|
||||
body, err := s.callModule(ctx, "leaveApply/getAppliedDays", "", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := xsfwUnmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析已请天数失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// XsfwFetchLeaveDetail 查询请假详情(qjxqbd,按申请编号)
|
||||
func (s *XsfwSession) XsfwFetchLeaveDetail(ctx context.Context, applyNo string) (map[string]interface{}, error) {
|
||||
resp, err := s.client.R().SetContext(ctx).
|
||||
SetHeader("Cookie", s.cookieStr()).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", s.root+"/*default/index.do").
|
||||
SetQueryParams(map[string]string{"*json": "1", "SQBH": applyNo}).
|
||||
Get(s.root + "/modules/wdqj/qjxqbd.do")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := xsfwUnmarshal(resp.Body(), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// XsfwLogin 建立学工应用会话。
|
||||
//
|
||||
// 关键(已实测确认):该校学工应用 `xg.ynpc.edu.cn` 与辅导猫 `ynpc.campusphere.net`
|
||||
// 是两个不同的 cookie 域,辅导猫登录拿到的 `MOD_AUTH_CAS`(值形如 `ST-iap:...`)
|
||||
// 在学工域**不被认可**,直接用会 302 回 CAS 登录页。
|
||||
// 正确做法:用辅导猫登录得到的 **CASTGC**(CAS 的 TGT)到 authserver 重新换一次
|
||||
// service 指向学工应用的票据,回调后即可在 xg 域建立会话,应用首页返回完整 HTML
|
||||
// (含 WIS_CONFIG: APPID=4810794463325921, APPNAME=xsqjapp)。
|
||||
//
|
||||
// baseCookies:辅导猫登录后的 cookie(必须含 CASTGC)。
|
||||
func XsfwLogin(ctx context.Context, schoolID string, baseCookies map[string]string) (*XsfwSession, error) {
|
||||
root := xsfwAppRoot(schoolID)
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
|
||||
}
|
||||
castgc := baseCookies["CASTGC"]
|
||||
if castgc == "" {
|
||||
return nil, fmt.Errorf("缺少 CASTGC,无法换取学工应用会话")
|
||||
}
|
||||
service := root + "/*default/index.do"
|
||||
host := xsfwCasBase(schoolID)
|
||||
|
||||
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
|
||||
// 1. 用 CASTGC 换票,只取 302 的 Location。
|
||||
// resty 在 NoRedirectPolicy 下遇到 302 会返回 error,但响应本身有效
|
||||
// (StatusCode/Location 都可读),因此这里忽略该 error。
|
||||
jar := newCookieJar()
|
||||
casClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetCookieJar(jar).
|
||||
SetRedirectPolicy(resty.NoRedirectPolicy()).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
loginURL := host + "/login?service=" + url.QueryEscape(service)
|
||||
resp, err := casClient.R().SetContext(ctx).Get(loginURL)
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("请求 CAS 失败: %w", err)
|
||||
}
|
||||
loc := resp.Header().Get("Location")
|
||||
if loc == "" && resp.RawResponse != nil {
|
||||
loc = resp.RawResponse.Header.Get("Location")
|
||||
}
|
||||
if loc == "" {
|
||||
return nil, fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已失效,status=%d)", resp.StatusCode())
|
||||
}
|
||||
|
||||
// 2. 跟随回调:另一个 client(允许重定向),共享同一个 jar 以收集全链路 cookie。
|
||||
// 注意 MOD_AUTH_CAS/_WEU 常在中间跳 302 时下发,只取最终响应会丢失。
|
||||
cbClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetCookieJar(jar).
|
||||
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
cb, err := cbClient.R().SetContext(ctx).Get(loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("跟随 CAS 回调失败: %w", err)
|
||||
}
|
||||
if cb.StatusCode() != 200 || len(cb.Body()) == 0 {
|
||||
return nil, fmt.Errorf("学工应用入口无内容(status=%d len=%d)", cb.StatusCode(), len(cb.Body()))
|
||||
}
|
||||
|
||||
// 3. 从 jar 取全链路 cookie
|
||||
sessionCookies := map[string]string{}
|
||||
for _, c := range jar.AllCookies() {
|
||||
sessionCookies[c.Name] = c.Value
|
||||
}
|
||||
sessionCookies["CASTGC"] = castgc
|
||||
|
||||
apiClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Cookie", cookiesToStr(sessionCookies))
|
||||
|
||||
return &XsfwSession{
|
||||
cookieMap: sessionCookies,
|
||||
root: root,
|
||||
client: apiClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// xsfwCasBase 返回该校 CAS 服务根地址
|
||||
func xsfwCasBase(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
|
||||
return "https://authserver.ynpc.edu.cn/authserver"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// XsfwCasEntry 返回该校 xsfw 请假应用的 CAS 登录入口。
|
||||
func XsfwCasEntry(schoolID string) string {
|
||||
root := xsfwAppRoot(schoolID)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return root + "/*default/index.do"
|
||||
}
|
||||
|
||||
// XsfwPage 分页辅助
|
||||
func XsfwPage(page, size int) string {
|
||||
return strconv.Itoa(page) + "/" + strconv.Itoa(size)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsXsfwSchool ynpc 走 xsfw 学工平台,其他学校不走。
|
||||
func TestIsXsfwSchool(t *testing.T) {
|
||||
const ynpc = "2970d544-2917-44e6-85db-f3245d6e3b1d"
|
||||
if !IsXsfwSchool(ynpc) {
|
||||
t.Fatal("云南警官学院应走 xsfw 学工平台")
|
||||
}
|
||||
if IsXsfwSchool("ynu") {
|
||||
t.Fatal("普通学校不应走 xsfw")
|
||||
}
|
||||
if xsfwAppRoot(ynpc) == "" {
|
||||
t.Fatal("ynpc 学工应用地址未配置")
|
||||
}
|
||||
if xsfwCasBase(ynpc) == "" {
|
||||
t.Fatal("ynpc CAS 地址未配置")
|
||||
}
|
||||
if XsfwCasEntry(ynpc) != "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp/*default/index.do" {
|
||||
t.Fatalf("xsfw CAS 入口错误: %s", XsfwCasEntry(ynpc))
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsfwUnmarshal 学工平台响应带多重 UTF-8 BOM,必须能正确解析。
|
||||
func TestXsfwUnmarshal(t *testing.T) {
|
||||
bom := []byte{0xEF, 0xBB, 0xBF}
|
||||
cases := []struct {
|
||||
name string
|
||||
in []byte
|
||||
}{
|
||||
{"无BOM", []byte(`{"totalSize":8}`)},
|
||||
{"单BOM", append(append([]byte{}, bom...), []byte(`{"totalSize":8}`)...)},
|
||||
{"双BOM", append(append(append([]byte{}, bom...), bom...), []byte(`{"totalSize":8}`)...)},
|
||||
{"BOM+前后空白", append(append(append([]byte{0x20}, bom...), bom...), []byte(" {\"totalSize\":8} ")...)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var v struct {
|
||||
TotalSize int `json:"totalSize"`
|
||||
}
|
||||
if err := xsfwUnmarshal(c.in, &v); err != nil {
|
||||
t.Fatalf("解析失败: %v (input=%q)", err, string(c.in))
|
||||
}
|
||||
if v.TotalSize != 8 {
|
||||
t.Fatalf("totalSize=%d, want 8", v.TotalSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsfwUnmarshalHTML 非 JSON(登录页 HTML)应报错而非静默成功。
|
||||
func TestXsfwUnmarshalHTML(t *testing.T) {
|
||||
var v map[string]interface{}
|
||||
if err := xsfwUnmarshal([]byte("<!DOCTYPE html><html></html>"), &v); err == nil {
|
||||
t.Fatal("HTML 应解析失败")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 把前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 忠实转写成参考实现,
|
||||
// 用它对拍 xsgzFlatten,避免再靠肉眼推断契约细节。
|
||||
//
|
||||
// 原文:
|
||||
//
|
||||
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
|
||||
// function v(e,t,a,o=false){
|
||||
// const r=t||new FormData
|
||||
// for(const n in e){
|
||||
// if(!Object.prototype.hasOwnProperty.call(e,n))continue
|
||||
// const s=e[n]
|
||||
// if(s===void 0)continue
|
||||
// const i=pe(a,n,o)
|
||||
// if(s instanceof Date) r.append(i,s.toISOString())
|
||||
// else if(Array.isArray(s))
|
||||
// if(s.some(l=>l&&typeof l=="object"))
|
||||
// s.forEach((l,p)=>{const c=`${i}[${p}]`
|
||||
// if(l&&typeof l=="object"){ if(l.status==="done")return
|
||||
// "file"in l&&(l.file instanceof File||l.file instanceof Blob)
|
||||
// ? r.append(o?`${c}[file]`:`${c}.file`,l.file,...)
|
||||
// : v(l,r,c,o) }
|
||||
// else r.append(c,l??""); })
|
||||
// else { const l=s.map(p=>p??"").join(","); r.append(i,l) }
|
||||
// else typeof s=="object"&&!(s instanceof File) ? v(s,r,i,o)
|
||||
// : r.append(i,s??"")
|
||||
// }
|
||||
// return r
|
||||
// }
|
||||
|
||||
// jsPe 对应 pe(e,t,a=false):对象键用点号。
|
||||
func jsPe(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// jsFlatten 对应 v(),返回前端会写入 FormData 的键值对。
|
||||
func jsFlatten(prefix string, v interface{}, out *[][2]string) {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
// JS: null 不是 undefined,会 append(key, null ?? "") 即空串
|
||||
*out = append(*out, [2]string{prefix, ""})
|
||||
case map[string]interface{}:
|
||||
for k, child := range val {
|
||||
jsFlatten(jsPe(prefix, k), child, out)
|
||||
}
|
||||
case []interface{}:
|
||||
hasObject := false
|
||||
for _, el := range val {
|
||||
if _, ok := el.(map[string]interface{}); ok {
|
||||
hasObject = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasObject {
|
||||
// 含对象 → 按下标逐个展开
|
||||
for i, child := range val {
|
||||
key := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
if _, ok := child.(map[string]interface{}); ok {
|
||||
jsFlatten(key, child, out)
|
||||
} else {
|
||||
*out = append(*out, [2]string{key, jsScalar(child)})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// 全是原始值 → join(",") 成单个键(关键差异点)
|
||||
parts := make([]string, 0, len(val))
|
||||
for _, el := range val {
|
||||
parts = append(parts, jsScalar(el))
|
||||
}
|
||||
*out = append(*out, [2]string{prefix, strings.Join(parts, ",")})
|
||||
case string:
|
||||
*out = append(*out, [2]string{prefix, val})
|
||||
case bool:
|
||||
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
|
||||
default:
|
||||
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
|
||||
}
|
||||
}
|
||||
|
||||
// jsScalar 对应 JS 的 `l ?? ""` 加隐式字符串化。
|
||||
func jsScalar(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// toMap 把键值对转成 map,便于比较。
|
||||
func toMap(pairs [][2]string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
m[kv[0]] = kv[1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// compareFlatten 对拍:对前端产出的每个非空键值,断言 Go 实现一致。
|
||||
//
|
||||
// 已知并有意的差异:前端会以空字符串提交(null → ""),我们跳过空值。
|
||||
// 对表单提交而言两者对必填校验等价,且跳过更干净,故此处不追究空值键。
|
||||
func compareFlatten(t *testing.T, name string, payload map[string]interface{}) {
|
||||
t.Helper()
|
||||
|
||||
var jsPairs, goPairs [][2]string
|
||||
jsFlatten("", payload, &jsPairs)
|
||||
xsgzFlatten("", payload, &goPairs)
|
||||
|
||||
js := toMap(jsPairs)
|
||||
got := toMap(goPairs)
|
||||
|
||||
for k, want := range js {
|
||||
if want == "" {
|
||||
continue // 有意差异:我们不提交空值
|
||||
}
|
||||
if gotVal, ok := got[k]; !ok {
|
||||
t.Errorf("%s: 缺少键 %q(前端提交 %q)", name, k, want)
|
||||
} else if gotVal != want {
|
||||
t.Errorf("%s: 键 %q 值不一致 got=%q want=%q", name, k, gotVal, want)
|
||||
}
|
||||
}
|
||||
// 反向:我们不应多提交前端没有的键
|
||||
for k, v := range got {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := js[k]; !ok {
|
||||
t.Errorf("%s: 多出键 %q=%q(前端无此键)", name, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFlattenMatchesFrontend 用真实形态的载荷对拍前后端序列化。
|
||||
func TestXsgzFlattenMatchesFrontend(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "请假提交(字段含 options 数组)",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "proj-1",
|
||||
"requestType": "LEAVE",
|
||||
"startTime": "2026-10-17 08:00:00",
|
||||
"endTime": "2026-10-18 18:00:00",
|
||||
"address": "山东农业大学",
|
||||
"longitude": "117.12",
|
||||
"latitude": "36.19",
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{
|
||||
"fieldId": "f1", "fieldName": "请假原因",
|
||||
"componentType": "TEXT", "fieldType": "TEXT",
|
||||
"required": true, "value": "感冒",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"label": "是", "value": "1"},
|
||||
map[string]interface{}{"label": "否", "value": "0"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "销假提交(含布尔与 originalId)",
|
||||
payload: map[string]interface{}{
|
||||
"originalId": "apply-99",
|
||||
"projectId": "proj-2",
|
||||
"requestType": "CANCELLATION",
|
||||
"inArea": true,
|
||||
"cancellationApplyInArea": false,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "c1", "value": "已返校"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "多字段多类型",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "a", "value": "文本"},
|
||||
map[string]interface{}{"fieldId": "b", "value": "x,y"},
|
||||
map[string]interface{}{"fieldId": "c", "value": "true"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "空 fields 数组",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"fields": []interface{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "嵌套 map",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"applicant": map[string]interface{}{
|
||||
"xgh": "202412303232", "xm": "张三",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
compareFlatten(t, c.name, c.payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFlattenArrayOfPrimitives 单列数组规则:前端 join(","),我们此前按下标。
|
||||
func TestXsgzFlattenArrayOfPrimitives(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"tags": []interface{}{"a", "b", "c"},
|
||||
}
|
||||
var jsPairs [][2]string
|
||||
jsFlatten("", payload, &jsPairs)
|
||||
js := toMap(jsPairs)
|
||||
t.Logf("前端产出: %v", js)
|
||||
|
||||
var goPairs [][2]string
|
||||
xsgzFlatten("", payload, &goPairs)
|
||||
got := toMap(goPairs)
|
||||
t.Logf("Go 产出: %v", got)
|
||||
|
||||
// 前端是一个键 tags="a,b,c"
|
||||
if js["tags"] != "a,b,c" {
|
||||
t.Fatalf("参考实现异常: %v", js)
|
||||
}
|
||||
if got["tags"] != "a,b,c" {
|
||||
t.Errorf("原始值数组应 join 成单键 tags=a,b,c,实际 %v", got)
|
||||
}
|
||||
// 不应出现索引键
|
||||
keys := make([]string, 0, len(got))
|
||||
for k := range got {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, "tags[") {
|
||||
t.Errorf("不应出现索引键 %q(前端会 join)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// 销假地理范围判断,对齐前端 LeaveCancelLocation 的 c():
|
||||
// Circle/poi 比球面距离,Polygon/Rectangle 用点在多边形内。
|
||||
|
||||
type pt = struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
}
|
||||
|
||||
// TestInLocationRangeEmpty 范围为空时不限制(前端:!r.value.length 时 return true)。
|
||||
func TestInLocationRangeEmpty(t *testing.T) {
|
||||
if !InLocationRange(nil, 117.12, 36.19) {
|
||||
t.Fatal("范围为空应视为不限制")
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{}, 117.12, 36.19) {
|
||||
t.Fatal("空切片应视为不限制")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeCircle 圆形范围:半径内 true,半径外 false。
|
||||
func TestInLocationRangeCircle(t *testing.T) {
|
||||
// 山东农业大学附近,半径 500 米
|
||||
center := XsgzLocationRange{
|
||||
CompatibleType: "Circle",
|
||||
Longitude: 117.12,
|
||||
Latitude: 36.19,
|
||||
Radius: 500,
|
||||
}
|
||||
// 圆心
|
||||
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.19) {
|
||||
t.Fatal("圆心应在范围内")
|
||||
}
|
||||
// 约 111 米外(纬度 0.001 度 ≈ 111 米)
|
||||
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.191) {
|
||||
t.Fatal("111 米外应在半径内")
|
||||
}
|
||||
// 约 1.1 公里外(0.01 度 ≈ 1111 米),应超出
|
||||
if InLocationRange([]XsgzLocationRange{center}, 117.12, 36.20) {
|
||||
t.Fatal("1.1 公里外应超出 500 米半径")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangePoi poi 类型按圆形处理。
|
||||
func TestInLocationRangePoi(t *testing.T) {
|
||||
r := XsgzLocationRange{
|
||||
CompatibleType: "poi",
|
||||
Longitude: 117.12,
|
||||
Latitude: 36.19,
|
||||
Radius: 200,
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{r}, 117.12, 36.19) {
|
||||
t.Fatal("poi 中心应在范围内")
|
||||
}
|
||||
if InLocationRange([]XsgzLocationRange{r}, 117.12, 36.25) {
|
||||
t.Fatal("远处应在范围外")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangePolygon 多边形:内部 true,外部 false。
|
||||
func TestInLocationRangePolygon(t *testing.T) {
|
||||
// 一个经纬度平面上的正方形,覆盖 117.10~117.14 × 36.17~36.21
|
||||
poly := XsgzLocationRange{
|
||||
CompatibleType: "Polygon",
|
||||
Points: []pt{
|
||||
{Longitude: 117.10, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.21},
|
||||
{Longitude: 117.10, Latitude: 36.21},
|
||||
},
|
||||
}
|
||||
ranges := []XsgzLocationRange{poly}
|
||||
|
||||
if !InLocationRange(ranges, 117.12, 36.19) {
|
||||
t.Fatal("中心点应在多边形内")
|
||||
}
|
||||
if InLocationRange(ranges, 117.20, 36.19) {
|
||||
t.Fatal("右侧外部点不应在多边形内")
|
||||
}
|
||||
if InLocationRange(ranges, 117.12, 36.30) {
|
||||
t.Fatal("上方外部点不应在多边形内")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeRectangle 矩形按多边形处理。
|
||||
func TestInLocationRangeRectangle(t *testing.T) {
|
||||
rect := XsgzLocationRange{
|
||||
CompatibleType: "Rectangle",
|
||||
Points: []pt{
|
||||
{Longitude: 117.10, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.21},
|
||||
{Longitude: 117.10, Latitude: 36.21},
|
||||
},
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{rect}, 117.13, 36.18) {
|
||||
t.Fatal("矩形内点应命中")
|
||||
}
|
||||
if InLocationRange([]XsgzLocationRange{rect}, 117.15, 36.18) {
|
||||
t.Fatal("矩形外点不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeMultiple 任一范围命中即可。
|
||||
func TestInLocationRangeMultiple(t *testing.T) {
|
||||
ranges := []XsgzLocationRange{
|
||||
{CompatibleType: "Circle", Longitude: 117.12, Latitude: 36.19, Radius: 100},
|
||||
{CompatibleType: "Circle", Longitude: 117.50, Latitude: 36.50, Radius: 100},
|
||||
}
|
||||
if !InLocationRange(ranges, 117.50, 36.50) {
|
||||
t.Fatal("第二个圆应命中")
|
||||
}
|
||||
if InLocationRange(ranges, 118.00, 37.00) {
|
||||
t.Fatal("都不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPointInPolygonDegenerate 少于 3 个点时不构成多边形。
|
||||
func TestPointInPolygonDegenerate(t *testing.T) {
|
||||
if pointInPolygon(0, 0, nil) {
|
||||
t.Fatal("空点集不应命中")
|
||||
}
|
||||
one := []pt{{Longitude: 0, Latitude: 0}}
|
||||
if pointInPolygon(0, 0, one) {
|
||||
t.Fatal("单点不应命中")
|
||||
}
|
||||
two := []pt{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
|
||||
if pointInPolygon(0.5, 0.5, two) {
|
||||
t.Fatal("两点不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzLeaveRecordRequiresInArea 归一化前端 `=== true || === "是"` 的语义。
|
||||
func TestXsgzLeaveRecordRequiresInArea(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
val interface{}
|
||||
want bool
|
||||
}{
|
||||
{"布尔 true", true, true},
|
||||
{"布尔 false", false, false},
|
||||
{"字符串 是", "是", true},
|
||||
{"字符串 否", "否", false},
|
||||
{"字符串 true", "true", false},
|
||||
{"nil", nil, false},
|
||||
{"数字", float64(1), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := XsgzLeaveRecord{CancellationApplyInArea: c.val}
|
||||
if got := rec.RequiresInArea(); got != c.want {
|
||||
t.Fatalf("%s: RequiresInArea()=%v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
|
||||
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
|
||||
//
|
||||
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
|
||||
//
|
||||
// c = ee(l.value, r.value) // 表单值
|
||||
// L = ae(c, r.value) // fields 数组
|
||||
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
|
||||
// f = le(T, D.value) // → Ne→v→pe,转 FormData
|
||||
// await te(f) // POST application_submit (multipart)
|
||||
//
|
||||
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
|
||||
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
|
||||
|
||||
// newXsgzTestSession 构造一个指向假服务器的会话。
|
||||
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
|
||||
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
|
||||
t.Helper()
|
||||
return &XsgzSession{
|
||||
Token: "test-access-token",
|
||||
RoleID: "role-123",
|
||||
baseURL: srv.URL,
|
||||
client: resty.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// captureMultipart 记录一次提交请求的表单元数据与键值。
|
||||
type captureMultipart struct {
|
||||
contentType string
|
||||
authHeader string
|
||||
roleHeader string
|
||||
path string
|
||||
form map[string]string
|
||||
// 出现顺序,用于确认 fields[] 按下标展开
|
||||
keys []string
|
||||
}
|
||||
|
||||
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
|
||||
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
|
||||
t.Helper()
|
||||
c.path = r.URL.Path
|
||||
c.contentType = r.Header.Get("Content-Type")
|
||||
c.authHeader = r.Header.Get("Authorization")
|
||||
c.roleHeader = r.Header.Get("roleId")
|
||||
c.form = map[string]string{}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(c.contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Content-Type 失败: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
|
||||
}
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
t.Fatal("缺少 multipart boundary")
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "" {
|
||||
continue // 文件部分
|
||||
}
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
n, rerr := part.Read(buf)
|
||||
if n > 0 {
|
||||
sb.Write(buf[:n])
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.form[name] = sb.String()
|
||||
c.keys = append(c.keys, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
|
||||
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
|
||||
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
|
||||
// 空值字段:前端 toFormatFormData 会跳过空字符串
|
||||
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
|
||||
}
|
||||
|
||||
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "proj-uuid-1",
|
||||
StartTime: "2026-10-17 08:00:00",
|
||||
EndTime: "2026-10-18 18:00:00",
|
||||
Address: "山东农业大学",
|
||||
Longitude: "117.12",
|
||||
Latitude: "36.19",
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":0`) {
|
||||
t.Fatalf("响应解析异常: %s", body)
|
||||
}
|
||||
|
||||
// 路径与鉴权头
|
||||
if cap.path != "/api/leave/student_action/application_submit" {
|
||||
t.Fatalf("路径错误: %s", cap.path)
|
||||
}
|
||||
if cap.authHeader != "test-access-token" {
|
||||
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
|
||||
}
|
||||
if cap.roleHeader != "role-123" {
|
||||
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
|
||||
}
|
||||
|
||||
// 顶层字段名必须与前端一致
|
||||
wantTop := map[string]string{
|
||||
"projectId": "proj-uuid-1",
|
||||
"requestType": "LEAVE",
|
||||
"startTime": "2026-10-17 08:00:00",
|
||||
"endTime": "2026-10-18 18:00:00",
|
||||
"address": "山东农业大学",
|
||||
"longitude": "117.12",
|
||||
"latitude": "36.19",
|
||||
}
|
||||
for k, want := range wantTop {
|
||||
if got := cap.form[k]; got != want {
|
||||
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// fields 必须是「数组下标方括号 + 对象键点号」:
|
||||
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
|
||||
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
|
||||
if got := cap.form["fields[0].fieldId"]; got != "f1" {
|
||||
t.Fatalf("fields[0].fieldId = %q, want f1", got)
|
||||
}
|
||||
if got := cap.form["fields[0].value"]; got != "身体不适" {
|
||||
t.Fatalf("fields[0].value = %q", got)
|
||||
}
|
||||
if got := cap.form["fields[1].value"]; got != "回家休息" {
|
||||
t.Fatalf("fields[1].value = %q", got)
|
||||
}
|
||||
// 空值字段的 value 不应出现
|
||||
if _, ok := cap.form["fields[2].value"]; ok {
|
||||
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
|
||||
}
|
||||
// 但该字段的非空属性仍应提交
|
||||
if got := cap.form["fields[2].fieldId"]; got != "f3" {
|
||||
t.Fatalf("fields[2].fieldId = %q", got)
|
||||
}
|
||||
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
|
||||
for k := range cap.form {
|
||||
if strings.Count(k, "[") != strings.Count(k, "]") {
|
||||
t.Fatalf("键 %q 括号不配对", k)
|
||||
}
|
||||
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
|
||||
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
|
||||
}
|
||||
}
|
||||
// 请假请求不应带销假专有字段
|
||||
if _, ok := cap.form["originalId"]; ok {
|
||||
t.Fatal("请假不应携带 originalId")
|
||||
}
|
||||
if _, ok := cap.form["inArea"]; ok {
|
||||
t.Fatal("请假不应携带 inArea")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
|
||||
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "proj-uuid-2",
|
||||
OriginalID: "orig-apply-99",
|
||||
Address: "山东农业大学",
|
||||
Longitude: "117.12",
|
||||
Latitude: "36.19",
|
||||
InArea: true,
|
||||
CancellationApplyInArea: true,
|
||||
Fields: []map[string]interface{}{
|
||||
{"fieldId": "c1", "value": "已返校"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("销假提交失败: %v", err)
|
||||
}
|
||||
|
||||
if got := cap.form["requestType"]; got != "CANCELLATION" {
|
||||
t.Fatalf("requestType = %q, want CANCELLATION", got)
|
||||
}
|
||||
if got := cap.form["originalId"]; got != "orig-apply-99" {
|
||||
t.Fatalf("originalId = %q, want orig-apply-99", got)
|
||||
}
|
||||
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
|
||||
if got := cap.form["inArea"]; got != "true" {
|
||||
t.Fatalf("inArea = %q, want true", got)
|
||||
}
|
||||
if got := cap.form["cancellationApplyInArea"]; got != "true" {
|
||||
t.Fatalf("cancellationApplyInArea = %q, want true", got)
|
||||
}
|
||||
// 销假不应带 startTime/endTime(未提供时)
|
||||
if _, ok := cap.form["startTime"]; ok {
|
||||
t.Fatal("销假未提供 startTime 时不应提交该键")
|
||||
}
|
||||
if got := cap.form["fields[0].value"]; got != "已返校" {
|
||||
t.Fatalf("fields[0].value = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
|
||||
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
|
||||
if err == nil {
|
||||
t.Fatal("缺少 originalId 应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "originalId") {
|
||||
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("参数校验失败时不应发起请求")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
|
||||
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("不应发起请求")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
|
||||
t.Fatal("缺少 projectId 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
|
||||
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
|
||||
}); err != nil {
|
||||
t.Fatalf("提交失败: %v", err)
|
||||
}
|
||||
for k := range cap.form {
|
||||
if strings.HasPrefix(k, "fields") {
|
||||
t.Fatalf("fields 为空时不应提交 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailContract 详情接口:路径、参数名、标记解析。
|
||||
func TestXsgzFetchLeaveDetailContract(t *testing.T) {
|
||||
var gotPath, gotLeaveID string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotLeaveID = r.URL.Query().Get("leaveId")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"apply-1","projectId":"proj-1","projectName":"病假",
|
||||
"status":"休假中","cancellationApplyInArea":true
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
d, err := sess.XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if gotPath != "/api/leave/student_action/leave_detail" {
|
||||
t.Fatalf("路径错误: %s", gotPath)
|
||||
}
|
||||
// 前端用 params 传递,键名必须是 leaveId
|
||||
if gotLeaveID != "apply-1" {
|
||||
t.Fatalf("leaveId 查询参数错误: %q", gotLeaveID)
|
||||
}
|
||||
if d == nil || d.ID != "apply-1" || d.ProjectID != "proj-1" {
|
||||
t.Fatalf("详情解析异常: %+v", d)
|
||||
}
|
||||
if !d.RequiresInArea() {
|
||||
t.Fatal("cancellationApplyInArea=true 应判定为需要范围内申请")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailStringYes 该标记也可能是字符串「是」。
|
||||
func TestXsgzFetchLeaveDetailStringYes(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"a","cancellationApplyInArea":"是"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if !d.RequiresInArea() {
|
||||
t.Fatal("字符串「是」应判定为需要范围内申请")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailError 非 0 code 必须报错,不能当成空详情。
|
||||
func TestXsgzFetchLeaveDetailError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":400,"message":"记录不存在"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "x"); err == nil {
|
||||
t.Fatal("code!=0 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
|
||||
//
|
||||
// 前端不依赖 cas/callback 返回 roleId:
|
||||
//
|
||||
// getOAuthToken 只存 access_token
|
||||
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
|
||||
func TestXsgzFetchUserRoleContract(t *testing.T) {
|
||||
var gotPath, gotMethod string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod = r.URL.Path, r.Method
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"realName":"张三",
|
||||
"role":{"roleId":"role-777","roleName":"学生"}
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
info, err := sess.fetchUserRole(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("获取用户角色失败: %v", err)
|
||||
}
|
||||
if gotPath != "/api/auth/user" {
|
||||
t.Fatalf("路径错误: %s", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
|
||||
}
|
||||
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
|
||||
t.Fatalf("解析异常: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
|
||||
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("失败: %v", err)
|
||||
}
|
||||
if info.RealName != "李四" {
|
||||
t.Fatalf("应回退 xm,实际 %q", info.RealName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
|
||||
func TestXsgzFetchUserRoleError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
|
||||
t.Fatal("code!=0 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
|
||||
//
|
||||
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
|
||||
//
|
||||
// service.post("/cas/callback", `ticket=${s}`,
|
||||
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
|
||||
//
|
||||
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
|
||||
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
|
||||
func casCallbackBody(enc string) string {
|
||||
return "ticket=" + enc
|
||||
}
|
||||
|
||||
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
|
||||
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
|
||||
// 用真实加密产物验证:多块时含空格
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
long := vecXsgzTicket
|
||||
for i := 0; i < 10; i++ {
|
||||
long += "0123456789"
|
||||
}
|
||||
enc, err := xsgzEncryptTicket(long, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(enc, " ") {
|
||||
t.Skip("该密钥长度下未产生多块,无法验证空格")
|
||||
}
|
||||
|
||||
body := casCallbackBody(enc)
|
||||
if strings.Contains(body, "+") {
|
||||
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
|
||||
}
|
||||
if !strings.Contains(body, "ticket="+enc) {
|
||||
t.Fatal("body 应为 ticket=<原始密文>")
|
||||
}
|
||||
// 关键:空格未被编码
|
||||
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
|
||||
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
|
||||
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
|
||||
plain := "ticket=aaa bbb"
|
||||
escaped := "ticket=" + url.QueryEscape("aaa bbb")
|
||||
if escaped == plain {
|
||||
t.Fatal("前提失效:QueryEscape 未改变空格")
|
||||
}
|
||||
if !strings.Contains(escaped, "+") {
|
||||
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
|
||||
//
|
||||
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
|
||||
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
|
||||
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"apply-1",
|
||||
"project":{"id":"proj-nested","text":"病假"},
|
||||
"status":"休假中"
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if d.ProjectID != "proj-nested" {
|
||||
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
|
||||
}
|
||||
if d.ProjectName != "病假" {
|
||||
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
|
||||
}
|
||||
}
|
||||
|
||||
// 顶层 projectId 优先于 project.id。
|
||||
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("失败: %v", err)
|
||||
}
|
||||
if d.ProjectID != "proj-top" {
|
||||
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
|
||||
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
|
||||
func TestCasTicketNameVariants(t *testing.T) {
|
||||
cases := []struct{ name, rawQuery string }{
|
||||
{"ticket", "ticket=ST-1"},
|
||||
{"service_ticket", "service_ticket=ST-2"},
|
||||
{"ST", "ST=ST-3"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tk := u.Query().Get("ticket")
|
||||
if tk == "" {
|
||||
tk = u.Query().Get("service_ticket")
|
||||
}
|
||||
if tk == "" {
|
||||
tk = u.Query().Get("ST")
|
||||
}
|
||||
if tk == "" {
|
||||
t.Fatalf("参数名 %s 未取到 ticket", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// truncateForLog 截断保护:不刷日志、不切断多字节字符。
|
||||
func TestTruncateForLog(t *testing.T) {
|
||||
if got := truncateForLog("abc", 10); got != "abc" {
|
||||
t.Fatalf("短串不应截断,实际 %q", got)
|
||||
}
|
||||
long := strings.Repeat("中", 400)
|
||||
got := truncateForLog(long, 300)
|
||||
if !strings.HasSuffix(got, "...(truncated)") {
|
||||
t.Fatal("超长应加截断标记")
|
||||
}
|
||||
// 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符)
|
||||
body := strings.TrimSuffix(got, "...(truncated)")
|
||||
if len([]rune(body)) != 300 {
|
||||
t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body)))
|
||||
}
|
||||
for _, r := range body {
|
||||
if r != '中' {
|
||||
t.Fatalf("出现乱码字符 %q", r)
|
||||
}
|
||||
}
|
||||
if got := truncateForLog("x", 0); got != "" {
|
||||
t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// isXsgzDecodeError 只对「参数解码错误」为真,其它错误不应触发重试。
|
||||
func TestIsXsgzDecodeError(t *testing.T) {
|
||||
// resty.Response.Body() 要求 RawResponse 非 nil,否则返回空切片
|
||||
mk := func(body string) *resty.Response {
|
||||
r := &resty.Response{RawResponse: &http.Response{StatusCode: 422}}
|
||||
r.SetBody([]byte(body))
|
||||
return r
|
||||
}
|
||||
if !isXsgzDecodeError(mk(`{"code":1,"message":"参数解码错误,刷新页面后尝试"}`)) {
|
||||
t.Fatal("应识别为解码错误")
|
||||
}
|
||||
if isXsgzDecodeError(mk(`{"code":1,"message":"用户名或密码错误"}`)) {
|
||||
t.Fatal("凭证错误不应触发重试")
|
||||
}
|
||||
if isXsgzDecodeError(nil) {
|
||||
t.Fatal("nil 不应判定为解码错误")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成,
|
||||
// 用于确保 Go 实现与浏览器行为逐字节一致。
|
||||
// 注意:模数 M 是学校固定的;DK 盐每次 /api/sec_js 都变,此处为取样时的一次值。
|
||||
const (
|
||||
vecXsgzDK = "54b6fay56a10ef75coxxjxl4yryye31u"
|
||||
vecXsgzE = "010001"
|
||||
vecXsgzM = "00b36e050adb7347cacb80ea0f7831ff64cdf8569adfb5a19099e95870f4b6aab43f75a221b1090e3a9ed91b420c72a6e38dc3b5594ddc30908db5266d5bf4474586080d0eb425bbc185185eec64c4596e6113d08fba77337b4171d91daad26b4571f1108ddf687fca25d8d5a75e3ec5dd698dff8446329c78e63d170eb401da55"
|
||||
vecXsgzTicket = "ST-UNIT-TEST-0001"
|
||||
vecXsgzWant = "3651cc9f29c5ff7522f648c0bd2fc671bb2009e82b7860bc2714238dbaf55a90a811dcc0d736d5e6185c084720b72a17b059d5dd2558ce44ed8a2f39aa60c85212acd8c6778377e4e80fb631a5a5259e557cf44750cf500bf0100860d0afb0027d8f1a93f5d725703a54bc3b0412c7e9e75a1bf4b4abce044a91d5acaadd0d19"
|
||||
)
|
||||
|
||||
// TestXsgzEncryptTicket 校验 RSA 加密与前端 RSAUtils 完全一致。
|
||||
func TestXsgzEncryptTicket(t *testing.T) {
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
got, err := xsgzEncryptTicket(vecXsgzTicket, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
if got != vecXsgzWant {
|
||||
t.Fatalf("密文与前端不一致:\n got=%s\nwant=%s", got, vecXsgzWant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzEncodeURIComponent 校验与 JS encodeURIComponent 等价(空格必须编 %20 而非 +)。
|
||||
func TestXsgzEncodeURIComponent(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"abcXYZ019", "abcXYZ019"},
|
||||
{"-_.!~*'()", "-_.!~*'()"},
|
||||
{"a b", "a%20b"}, // 关键:不能是 a+b
|
||||
{"a+b", "a%2Bb"}, // + 必须被编码
|
||||
{"ST-123", "ST-123"}, // ticket 常见字符
|
||||
{"中文", "%E4%B8%AD%E6%96%87"}, // UTF-8 逐字节
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := xsgzEncodeURIComponent(c.in); got != c.want {
|
||||
t.Fatalf("encodeURIComponent(%q)=%q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzEncryptChunkSize 校验分块长度 = 模数字节数-2。
|
||||
func TestXsgzEncryptChunkSize(t *testing.T) {
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
// 构造足够长的 ticket 触发多块
|
||||
long := vecXsgzTicket
|
||||
for i := 0; i < 10; i++ {
|
||||
long += "0123456789"
|
||||
}
|
||||
got, err := xsgzEncryptTicket(long, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
blocks := splitSpace(got)
|
||||
if len(blocks) < 2 {
|
||||
t.Fatalf("长输入应产生多块,实际 %d 块", len(blocks))
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if len(b) > 256 {
|
||||
t.Fatalf("块长度 %d 超过模数 hex 长度 256", len(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitSpace(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, c := range s {
|
||||
if c == ' ' {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(c)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestIsXsgzSchool 校验按学校 ID 分流。
|
||||
func TestIsXsgzSchool(t *testing.T) {
|
||||
if !IsXsgzSchool("sdau") {
|
||||
t.Fatal("sdau 应走新学工5")
|
||||
}
|
||||
if IsXsgzSchool("ynu") {
|
||||
t.Fatal("普通学校不应走新学工5")
|
||||
}
|
||||
if xsgzBaseURL("sdau") == "" {
|
||||
t.Fatal("sdau 新学工地址未配置")
|
||||
}
|
||||
if XsgzCasServiceURL("sdau") != "https://xsgz.sdau.edu.cn/oauth/callback" {
|
||||
t.Fatalf("CAS service 地址错误: %s", XsgzCasServiceURL("sdau"))
|
||||
}
|
||||
}
|
||||
|
||||
// xsgzFlatten 必须与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 规则一致:
|
||||
// 对象键用点号、数组下标用方括号、跳过空值。
|
||||
func TestXsgzFlatten(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"projectId": "uuid-1",
|
||||
"requestType": "CANCELLATION",
|
||||
"address": "", // 空字符串应跳过
|
||||
"longitude": "115.8",
|
||||
"inArea": true,
|
||||
"empty": nil, // nil 应跳过
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "f1", "value": "v1", "blank": ""},
|
||||
map[string]interface{}{"fieldId": "f2", "value": "v2"},
|
||||
},
|
||||
}
|
||||
var pairs [][2]string
|
||||
xsgzFlatten("", in, &pairs)
|
||||
|
||||
got := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
got[kv[0]] = kv[1]
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"projectId": "uuid-1",
|
||||
"requestType": "CANCELLATION",
|
||||
"longitude": "115.8",
|
||||
"inArea": "true",
|
||||
"fields[0].fieldId": "f1",
|
||||
"fields[0].value": "v1",
|
||||
"fields[1].fieldId": "f2",
|
||||
"fields[1].value": "v2",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("key %q = %q, want %q(实际: %v)", k, got[k], v, got)
|
||||
}
|
||||
}
|
||||
// 空值不应出现
|
||||
for _, bad := range []string{"address", "empty", "fields[0].blank"} {
|
||||
if _, ok := got[bad]; ok {
|
||||
t.Fatalf("空值键 %q 不应被提交", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// casCaptchaMode 从登录页解析验证码形态。
|
||||
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
|
||||
// 该值会随风控状态变化(连续失败后可能由滑块切为图片验证码),
|
||||
// 必须按页面实际值处理,否则会一直走滑块分支而失败。
|
||||
func TestCasCaptchaMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
page string
|
||||
want casCaptchaKind
|
||||
}{
|
||||
{
|
||||
name: "captchaSwitch=1 图片验证码",
|
||||
page: `<script>var captchaSwitch = "1"; badCredentialsCount="3";</script>`,
|
||||
want: casCaptchaImage,
|
||||
},
|
||||
{
|
||||
name: "captchaSwitch=2 滑块",
|
||||
page: `<script>var captchaSwitch = "2"; badCredentialsCount="0";</script>`,
|
||||
want: casCaptchaSlider,
|
||||
},
|
||||
{
|
||||
name: "无 captchaSwitch 字段",
|
||||
page: `<html><body>login</body></html>`,
|
||||
want: casCaptchaUnknown,
|
||||
},
|
||||
{
|
||||
name: "带空格与制表符",
|
||||
page: "<script>\n\tvar captchaSwitch\t= \"2\" ;\n</script>",
|
||||
want: casCaptchaSlider,
|
||||
},
|
||||
{
|
||||
name: "未知取值按 unknown 处理",
|
||||
page: `<script>var captchaSwitch = "9";</script>`,
|
||||
want: casCaptchaUnknown,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := casCaptchaMode([]byte(c.page)); got != c.want {
|
||||
t.Fatalf("casCaptchaMode=%v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,10 +34,31 @@ func newSessionClient() *sessionClient {
|
||||
InsecureSkipVerify: true,
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}).
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
|
||||
// 浏览器常见头:部分学校(如通化师范学院 authserver.thnu.edu.cn)前置 WAF 按请求头
|
||||
// 指纹判定客户端,仅带 UA、缺少 Accept 类头会被判为机器人直接返回 429(空 body)。
|
||||
// 仅补 Accept/Accept-Language 即可通过校验(实测移动 UA + 这两项 → 200);
|
||||
// 保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。
|
||||
SetHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8").
|
||||
SetHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
client.SetCookieJar(jar)
|
||||
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
|
||||
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
|
||||
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
|
||||
// "go-resty/2.x"(见 resty redirect.go checkHostAndAddHeaders),而 wisedu 定制 CAS
|
||||
// 按请求 UA 渲染移动端/PC 模板。云南大学等学校从 campusphere 网关跨域跳到 CAS 时
|
||||
// UA 被替换,CAS 渲染 PC 模板导致后续 MFA 页面(reAuthLoginView.do)校验失败被
|
||||
// 302 踢回登录页,无法解析 reAuthType。保留 UA 后移动端模板正常渲染。
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 20 {
|
||||
return fmt.Errorf("stopped after %d redirects", 20)
|
||||
}
|
||||
if len(via) > 0 && !strings.EqualFold(getHostnameForRedirect(req.URL.Host), getHostnameForRedirect(via[0].URL.Host)) {
|
||||
if ua := via[0].Header.Get("User-Agent"); ua != "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
return &sessionClient{
|
||||
Client: client,
|
||||
jar: jar,
|
||||
@@ -52,7 +74,15 @@ func readBody(resp *resty.Response) []byte {
|
||||
return resp.Body()
|
||||
}
|
||||
|
||||
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
// getHostnameForRedirect 去掉端口后返回小写主机名(重定向跨域判断用)
|
||||
func getHostnameForRedirect(host string) string {
|
||||
if i := strings.Index(host, ":"); i > 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 获取合法的登录 URL
|
||||
@@ -65,7 +95,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
return nil, nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
}
|
||||
|
||||
loginPageBytes := readBody(loginPageResp)
|
||||
@@ -77,19 +107,41 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
// 4. 解析表单
|
||||
allInputs := parseLoginForm(loginPageBytes, username, password)
|
||||
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
|
||||
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
|
||||
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
||||
|
||||
// 5. 判断是否需要验证码
|
||||
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
|
||||
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
|
||||
if needCaptcha {
|
||||
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||
switch loginType {
|
||||
case 0:
|
||||
allInputs["captchaResponse"] = captchaCode
|
||||
default:
|
||||
allInputs["captcha"] = captchaCode
|
||||
// 验证码形态以登录页实际配置为准,而不是只看学校静态配置:
|
||||
// 同一学校的 captchaSwitch 会随风控状态变化(如连续失败后由滑块切换为图片验证码),
|
||||
// 若仍按滑块处理会因 openSliderCaptcha 返回空图而失败,表现为"登录失败/密码错误"。
|
||||
mode := casCaptchaMode(loginPageBytes)
|
||||
slog.Info("CAS验证码形态", "mode", mode, "isSliderSchool", isSliderCaptchaSchool(school.ID))
|
||||
if mode == casCaptchaSlider {
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
} else if mode == casCaptchaImage || !isSliderCaptchaSchool(school.ID) {
|
||||
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||
switch loginType {
|
||||
case 0:
|
||||
allInputs["captchaResponse"] = captchaCode
|
||||
default:
|
||||
allInputs["captcha"] = captchaCode
|
||||
}
|
||||
} else {
|
||||
// 页面未声明形态时,沿用学校级滑块配置
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +155,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
if loginResp != nil && loginResp.StatusCode() == 302 {
|
||||
slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
||||
} else {
|
||||
return nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +164,20 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
// 7. 处理响应
|
||||
switch loginResp.StatusCode() {
|
||||
case 302:
|
||||
slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location"))
|
||||
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
location := loginResp.Header().Get("Location")
|
||||
// 多因子认证:302 到 reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=...
|
||||
// 此时 CAS 会话有效但未签发票据,需自动发送验证码并等待用户提交
|
||||
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
|
||||
slog.Info("CAS触发多因子认证(MFA)", "location", location)
|
||||
mfa, err := handleMFATriggered(ctx, loginResp, school, sc)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, mfa, ErrMFARequired
|
||||
}
|
||||
slog.Info("CAS登录成功", "redirect", location)
|
||||
cookies, err := handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
return cookies, nil, err
|
||||
case 200, 401:
|
||||
body := readBody(loginResp)
|
||||
slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)]))
|
||||
@@ -121,15 +185,16 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
if err == nil && isCasNeedKickOut(doc) {
|
||||
slog.Info("CAS需要踢下线")
|
||||
kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client)
|
||||
return handleCasLoginSuccess(ctx, kickResp, school, sc)
|
||||
cookies, err := handleCasLoginSuccess(ctx, kickResp, school, sc)
|
||||
return cookies, nil, err
|
||||
}
|
||||
msg := getCasErrorMessage(doc)
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查账号密码"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +205,14 @@ func getCasValidLoginUrl(school *SchoolDetailInfo) string {
|
||||
return school.IdsUrl
|
||||
}
|
||||
}
|
||||
// 优先选择 campusphere / wec 网关入口:登录后 cookie 落在网关域,
|
||||
// 后续签到/查寝等任务请求(wec-counselor-* 均在网关域)才能携带有效会话。
|
||||
// 部分学校 AmpUrl 为学校自研门户(如 ehall),若登录到该域,cookie 与任务接口域不匹配。
|
||||
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||
if u != "" && (strings.Contains(u, "campusphere") || strings.Contains(u, "wec-portal-mobile")) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||
if u != "" {
|
||||
return u
|
||||
@@ -156,8 +229,10 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// 优先选择密码登录表单(pwdFromId),其次是其他已知CAS表单ID
|
||||
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#loginFromId", "#fm1", "form"}
|
||||
// 优先选择密码登录表单(pwdFromId/pwdLoginDiv),其次是其他已知CAS表单ID。
|
||||
// pwdLoginDiv 为 wisedu 定制 CAS 的账密区域(如华侨大学登录页含 3 个 id 均为
|
||||
// loginFromId 的表单 fido/动态码/账密,账密字段位于 #pwdLoginDiv 内)
|
||||
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#pwdLoginDiv", "#loginFromId", "#fm1", "form"}
|
||||
var formSel *goquery.Selection
|
||||
for _, sel := range selectors {
|
||||
formSel = doc.Find(sel)
|
||||
@@ -207,7 +282,40 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// 检测是否需要验证码
|
||||
// CAS 验证码形态
|
||||
type casCaptchaKind int
|
||||
|
||||
const (
|
||||
// casCaptchaUnknown 页面未声明(按学校级配置兜底)
|
||||
casCaptchaUnknown casCaptchaKind = iota
|
||||
// casCaptchaImage 图片验证码(captchaSwitch=1)
|
||||
casCaptchaImage
|
||||
// casCaptchaSlider 滑块验证码(captchaSwitch=2)
|
||||
casCaptchaSlider
|
||||
)
|
||||
|
||||
var casCaptchaSwitchRe = regexp.MustCompile(`captchaSwitch\s*=\s*"(\d+)"`)
|
||||
|
||||
// casCaptchaMode 从登录页读取当前要求的验证码形态。
|
||||
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
|
||||
// 该值会随风控状态变化(连续失败后可能由滑块切换为图片验证码),
|
||||
// 因此必须以页面实际值为准。
|
||||
func casCaptchaMode(page []byte) casCaptchaKind {
|
||||
m := casCaptchaSwitchRe.FindSubmatch(page)
|
||||
if len(m) < 2 {
|
||||
return casCaptchaUnknown
|
||||
}
|
||||
switch string(m[1]) {
|
||||
case "1":
|
||||
return casCaptchaImage
|
||||
case "2":
|
||||
return casCaptchaSlider
|
||||
default:
|
||||
return casCaptchaUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// detectCaptcha 判断是否需要验证码
|
||||
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
|
||||
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
|
||||
loginUrl.Scheme, loginUrl.Host, username)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
|
||||
//
|
||||
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
|
||||
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
|
||||
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
|
||||
//
|
||||
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
|
||||
|
||||
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
|
||||
func isCasPaasLoginPage(page []byte) bool {
|
||||
s := string(page)
|
||||
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
|
||||
}
|
||||
|
||||
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
|
||||
// casHost 形如 https://cas-paas.lut.edu.cn
|
||||
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
|
||||
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
pub := strings.TrimSpace(string(readBody(resp)))
|
||||
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
|
||||
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
pub = strings.ReplaceAll(pub, "\r", "")
|
||||
pub = strings.TrimSpace(pub)
|
||||
if pub == "" {
|
||||
return "", fmt.Errorf("CAS-PaaS 公钥为空")
|
||||
}
|
||||
key, err := NewRSAKeyPairFromPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
encHex, err := key.Encrypt(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
|
||||
}
|
||||
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
|
||||
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString(raw)
|
||||
return "__RSA__" + enc, nil
|
||||
}
|
||||
|
||||
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
|
||||
func casPaasBaseOf(loginURL *url.URL) string {
|
||||
if loginURL == nil {
|
||||
return ""
|
||||
}
|
||||
return loginURL.Scheme + "://" + loginURL.Host
|
||||
}
|
||||
|
||||
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
|
||||
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
|
||||
if !isCasPaasLoginPage(page) {
|
||||
return false
|
||||
}
|
||||
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
|
||||
if err != nil {
|
||||
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
|
||||
return false
|
||||
}
|
||||
inputs["password"] = enc
|
||||
slog.Info("CAS-PaaS 密码已加密")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
|
||||
func TestIsCasPaasLoginPage(t *testing.T) {
|
||||
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
|
||||
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
|
||||
}
|
||||
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
|
||||
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
|
||||
}
|
||||
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
|
||||
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
|
||||
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
|
||||
func TestCasPaasEncryptFormat(t *testing.T) {
|
||||
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
|
||||
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
|
||||
if err != nil {
|
||||
t.Fatalf("构造公钥失败: %v", err)
|
||||
}
|
||||
encHex, err := key.Encrypt("pw")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
t.Fatalf("hex 解码失败: %v", err)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
|
||||
t.Fatalf("base64 解码失败: %v", err)
|
||||
}
|
||||
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
|
||||
if len(raw) != 128 {
|
||||
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
|
||||
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
|
||||
@@ -0,0 +1,251 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 合肥师范学院专用登录适配 ==========
|
||||
//
|
||||
// 该校(hfnu.campusphere.net)登录走 portal.hfnu.edu.cn 老式 jasig CAS
|
||||
// (/sso/login,webflow lt/e1sX 风格),与通用 CAS 流程存在两处不兼容,
|
||||
// 故单独适配:
|
||||
// 1. 会话用 URL 重写传递(form action 形如 /sso/login;jsessionid=xxx?service=...),
|
||||
// 服务器不通过 Set-Cookie 建立 session,POST 必须发到 form 的 action
|
||||
// 地址(带 ;jsessionid),否则 webflow 会话丢失、静默回到登录页;
|
||||
// 2. 每次登录都要求图形验证码(页面 JS: codeupdate==0 → 强制显示
|
||||
// #code_validate),验证码图片 /sso/code/code.jsp,4 位纯数字 JPEG,
|
||||
// 提交字段为 validateCode。通用流程探测的 /authserver/needCaptcha.html
|
||||
// 在该校返回空 body,会被误判为"不需要验证码"。
|
||||
//
|
||||
// 密码为明文提交(页面无加密 salt),登录成功后 302 回跳
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess?sessionToken=...&ticket=...
|
||||
|
||||
// HfnuSchoolId 合肥师范学院 tenant id
|
||||
const HfnuSchoolId = "hfnu"
|
||||
|
||||
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
|
||||
|
||||
// hfnuLtKey 页面 JS 中固定写入的 lt 值(var keys = "e1s1")
|
||||
const hfnuLtKey = "e1s1"
|
||||
|
||||
// hfnuLogin 合肥师范学院专用登录
|
||||
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
slog.Info("合肥师范专用登录", "loginUrl", loginUrl)
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. GET 登录页(跟随重定向到 portal.hfnu.edu.cn/sso/login)
|
||||
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
}
|
||||
pageBody := readBody(loginPageResp)
|
||||
pageURL := loginPageResp.RawResponse.Request.URL
|
||||
slog.Info("合肥师范登录页", "finalUrl", pageURL.String(), "bodyLen", len(pageBody))
|
||||
|
||||
// 2. 解析 form action(含 ;jsessionid)与 lt
|
||||
postUrl, lt, err := parseHfnuLoginForm(pageBody, pageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("合肥师范CAS表单", "postUrl", postUrl, "lt", lt)
|
||||
|
||||
// 3. 获取验证码并 OCR(该校每次登录都需要)
|
||||
captchaCode, err := resolveHfnuCaptcha(ctx, pageURL, postUrl, sc.Client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 先校验验证码:页面 JS 在真正 submit 之前会 AJAX 调
|
||||
// /sso/code/validationCode.jsp(POST validateCode),只有返回
|
||||
// {"success":true} 才继续。跳过这一步服务端直接退回登录页,
|
||||
// 且不给任何错误提示(实测:正确密码与错误密码表现完全一致)。
|
||||
validationUrl, vErr := pageURL.Parse("code/validationCode.jsp")
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("解析验证码校验地址失败: %w", vErr)
|
||||
}
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
validationUrl.Path = validationUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
vResp, vErr := sc.R().SetContext(loginCtx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{"validateCode": captchaCode}).
|
||||
Post(validationUrl.String())
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("校验验证码失败: %w", vErr)
|
||||
}
|
||||
vBody := readBody(vResp)
|
||||
var vResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
_ = json.Unmarshal(vBody, &vResult)
|
||||
slog.Info("合肥师范验证码校验", "resp", truncateForLog(string(vBody), 120), "success", vResult.Success)
|
||||
if !vResult.Success {
|
||||
return nil, fmt.Errorf("验证码识别结果不被接受(%s),请重试", captchaCode)
|
||||
}
|
||||
|
||||
// 5. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 页面 JS 的关键处理:password 先 base64 编码,lt 替换为 keys("e1s1")。
|
||||
// 少任一步认证都不会通过,且服务端静默退回登录页。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().
|
||||
SetContext(loginCtx).
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": base64.StdEncoding.EncodeToString([]byte(password)),
|
||||
"validateCode": captchaCode,
|
||||
"lt": hfnuLtKey,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
if err != nil {
|
||||
if loginResp != nil && loginResp.StatusCode() == http.StatusFound {
|
||||
slog.Info("合肥师范登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
||||
} else {
|
||||
return nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("合肥师范登录响应", "statusCode", loginResp.StatusCode())
|
||||
switch loginResp.StatusCode() {
|
||||
case http.StatusFound:
|
||||
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
case http.StatusOK, http.StatusUnauthorized:
|
||||
// 2026-09 排查:该校认证流程本身可用(账密/验证码校验均可通过),
|
||||
// 但 CAS 认证通过后校方 amp-auth-adapter 单点跳转服务拒绝授权
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
respBody := readBody(loginResp)
|
||||
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
|
||||
msg := parseHfnuLoginError(respBody)
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
}
|
||||
|
||||
// parseHfnuLoginForm 从登录页解析 form action(绝对化,保留 ;jsessionid)与 lt
|
||||
func parseHfnuLoginForm(page []byte, pageURL *url.URL) (string, string, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("解析登录页失败: %w", err)
|
||||
}
|
||||
form := doc.Find("#credentials")
|
||||
if form.Length() == 0 {
|
||||
form = doc.Find("form").First()
|
||||
}
|
||||
if form.Length() == 0 {
|
||||
return "", "", fmt.Errorf("登录页未找到登录表单")
|
||||
}
|
||||
|
||||
action, _ := form.Attr("action")
|
||||
postUrl := ""
|
||||
if action != "" {
|
||||
if u, err := pageURL.Parse(action); err == nil {
|
||||
postUrl = u.String()
|
||||
}
|
||||
}
|
||||
if postUrl == "" {
|
||||
return "", "", fmt.Errorf("登录页未找到表单提交地址")
|
||||
}
|
||||
|
||||
lt := ""
|
||||
form.Find("input").Each(func(_ int, s *goquery.Selection) {
|
||||
name, _ := s.Attr("name")
|
||||
if name == "lt" {
|
||||
lt, _ = s.Attr("value")
|
||||
}
|
||||
})
|
||||
if lt == "" {
|
||||
return "", "", fmt.Errorf("登录页未找到 lt 参数")
|
||||
}
|
||||
return postUrl, lt, nil
|
||||
}
|
||||
|
||||
// resolveHfnuCaptcha 拉取 /sso/code/code.jsp 验证码并 OCR。
|
||||
// 该校 session 走 URL 重写,验证码请求需带上与 form action 相同的 ;jsessionid。
|
||||
func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, client *resty.Client) (string, error) {
|
||||
// 登录页路径为 /sso/login;相对路径 code/code.jsp 基于其解析 → /sso/code/code.jsp
|
||||
captchaUrl, err := pageURL.Parse("code/code.jsp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析验证码地址失败: %w", err)
|
||||
}
|
||||
// 该校不依赖 Cookie 而用 URL 重写:把 form action 中的 jsessionid 拼到验证码路径
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
captchaUrl.Path = captchaUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
captchaUrl.RawQuery = fmt.Sprintf("t=%.3d", time.Now().UnixNano()%1000)
|
||||
slog.Info("合肥师范验证码", "url", captchaUrl.String())
|
||||
|
||||
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取验证码失败: %w", err)
|
||||
}
|
||||
imgData := readBody(imgResp)
|
||||
if len(imgData) == 0 {
|
||||
return "", fmt.Errorf("验证码图片为空")
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
raw := captchaOCR(imgData)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("验证码识别失败,请重试")
|
||||
}
|
||||
// 该校验证码为 4 位纯数字(/sso/code/code.jsp)。
|
||||
// OCR 偶发多识别位数(实测出现过 5 位),这里只保留数字并截取前 4 位,
|
||||
// 避免把非法长度提交给 validationCode.jsp 直接被判"验证码输入错误"。
|
||||
digits := make([]rune, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, r)
|
||||
}
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return "", fmt.Errorf("验证码识别结果无数字(%q)", raw)
|
||||
}
|
||||
if len(digits) > 4 {
|
||||
digits = digits[:4]
|
||||
}
|
||||
code := string(digits)
|
||||
if len(code) != 4 {
|
||||
return "", fmt.Errorf("验证码识别结果长度异常(%q → %q),请重试", raw, code)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// parseHfnuLoginError 解析登录失败页的错误提示
|
||||
func parseHfnuLoginError(body []byte) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
|
||||
if err == nil {
|
||||
if msg := strings.TrimSpace(doc.Find("li.err").Text()); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 合肥工业大学(hfut)单独适配 ==========
|
||||
|
||||
// HfutSchoolId 合肥工业大学 tenant id
|
||||
const HfutSchoolId = "46a6ac8d-0795-4759-b777-c9aa0148b959"
|
||||
|
||||
// hfutCasEntry 计算合肥工业大学 CAS 登录入口:
|
||||
// 网关域 hfut.campusphere.net 的 /iap(school.IdsUrl)会 302 到学校 CAS
|
||||
// (cas.hfut.edu.cn/cas/login),service 参数携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
|
||||
// cookie 才能正确落在 hfut.campusphere.net 域(wec-* 任务接口所在域)。
|
||||
func hfutCasEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:
|
||||
// 前端 encryptionPwd() 用 AES-ECB/PKCS7,key = cookie LOGIN_FLAVORING(该 cookie 由
|
||||
// /cas/checkInitParams 下发),明文密码 UTF-8 编码后加密,输出 Base64。
|
||||
func hfutEncryptPassword(password, key string) (string, error) {
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("缺少密码加密密钥(LOGIN_FLAVORING)")
|
||||
}
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("初始化 AES 失败: %w", err)
|
||||
}
|
||||
plain := pkcs7Pad([]byte(password), block.BlockSize())
|
||||
encrypted := make([]byte, len(plain))
|
||||
// AES-ECB:逐块独立加密
|
||||
for i := 0; i < len(plain); i += block.BlockSize() {
|
||||
block.Encrypt(encrypted[i:i+block.BlockSize()], plain[i:i+block.BlockSize()])
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
var hfutExecutionRe = regexp.MustCompile(`name="execution"\s+value="([^"]*)"`)
|
||||
|
||||
// hfutCasLogin 合肥工业大学强制走其 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),idsUrl=hfut.campusphere.net/iap,
|
||||
// 但网关 IAP 账密接口与本链路不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
|
||||
// (标准 Apereo CAS,表单 id=fm1)→ 密码 AES-ECB(LOGIN_FLAVORING) 加密后 POST 登录
|
||||
// → 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
|
||||
//
|
||||
// 关键差异(通用 casLogin 会报"账号或密码错误"的原因):
|
||||
// 1. 密码必须 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie;
|
||||
// 2. CAS 有自研 /cas/checkInitParams 初始化接口,通用流程从不调用,故拿不到加密 key。
|
||||
func hfutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
entry := hfutCasEntry(school)
|
||||
if entry == "" {
|
||||
return nil, nil, fmt.Errorf("合肥工业大学登录入口缺失")
|
||||
}
|
||||
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 从网关 /iap 进入,302 到 CAS 登录页(带 service 回调地址)
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(entry)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
|
||||
}
|
||||
loginURL := pageResp.RawResponse.Request.URL
|
||||
if loginURL == nil || !strings.Contains(loginURL.String(), "/cas/login") {
|
||||
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
loginURLStr := loginURL.String()
|
||||
pageBody := readBody(pageResp)
|
||||
casBase := fmt.Sprintf("%s://%s/cas", loginURL.Scheme, loginURL.Host)
|
||||
|
||||
// 2. 调 checkInitParams 获取密码加密密钥(LOGIN_FLAVORING cookie)
|
||||
if _, cerr := sc.R().SetContext(ctx).Get(casBase + "/checkInitParams"); cerr != nil {
|
||||
return nil, nil, fmt.Errorf("获取登录初始化参数失败: %w", cerr)
|
||||
}
|
||||
flavoring := ""
|
||||
for _, ck := range sc.jar.AllCookies() {
|
||||
if ck.Name == "LOGIN_FLAVORING" {
|
||||
flavoring = ck.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if flavoring == "" {
|
||||
return nil, nil, fmt.Errorf("获取密码加密密钥失败(LOGIN_FLAVORING 缺失)")
|
||||
}
|
||||
encPwd, err := hfutEncryptPassword(password, flavoring)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 3. 解析表单 execution(Apereo CAS CSRF token)
|
||||
execution := ""
|
||||
if m := hfutExecutionRe.FindSubmatch(pageBody); len(m) > 1 {
|
||||
execution = string(m[1])
|
||||
}
|
||||
|
||||
submitData := map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"execution": execution,
|
||||
"_eventId": "submit",
|
||||
"geolocation": "",
|
||||
}
|
||||
|
||||
// 4. 提交登录。禁用自动重定向以捕获 302 回调地址与 200 失败页。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().SetContext(ctx).SetFormData(submitData).Post(loginURLStr)
|
||||
if err != nil && (loginResp == nil || loginResp.StatusCode() != 302) {
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
|
||||
switch loginResp.StatusCode() {
|
||||
case 302:
|
||||
location := loginResp.Header().Get("Location")
|
||||
// 多因子认证(如有)
|
||||
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
|
||||
mfa, merr := handleMFATriggered(ctx, loginResp, school, sc)
|
||||
if merr != nil {
|
||||
return nil, nil, merr
|
||||
}
|
||||
return nil, mfa, ErrMFARequired
|
||||
}
|
||||
if location == "" {
|
||||
return nil, nil, fmt.Errorf("登录失败,未获取到回调地址")
|
||||
}
|
||||
// 跟随回调建立网关会话
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
if _, cerr := sc.R().SetContext(ctx).Get(location); cerr != nil {
|
||||
return nil, nil, fmt.Errorf("跟随登录回调失败: %w", cerr)
|
||||
}
|
||||
case 200:
|
||||
// 登录失败:尝试识别错误信息(含验证码要求时重试)
|
||||
body := readBody(loginResp)
|
||||
msg := hfutCasError(body)
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查账号密码"
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||
|
||||
cookies := sc.jar.AllCookies()
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
return cookies, nil, nil
|
||||
}
|
||||
|
||||
// hfutCasError 从 CAS 登录失败页提取可读错误信息(简化实现,够用即止)。
|
||||
func hfutCasError(body []byte) string {
|
||||
s := string(body)
|
||||
// 常见错误容器:#msg / #errorMsg / 类 errMessage
|
||||
for _, key := range []string{`id="msg"`, `id="errorMsg"`, `class="errMessage"`} {
|
||||
if i := strings.Index(s, key); i >= 0 {
|
||||
seg := s[i:]
|
||||
if j := strings.Index(seg, ">"); j >= 0 {
|
||||
rest := seg[j+1:]
|
||||
if k := strings.Index(rest, "<"); k > 0 {
|
||||
txt := strings.TrimSpace(rest[:k])
|
||||
if txt != "" {
|
||||
return txt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兜底:JSON 错误体
|
||||
var r struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if json.Unmarshal(body, &r) == nil && r.Msg != "" {
|
||||
return r.Msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:AES-ECB/PKCS7,Base64 输出。
|
||||
func TestHfutEncryptPassword(t *testing.T) {
|
||||
// 16 字节 key(LOGIN_FLAVORING 为 16 位字母数字)
|
||||
key := "63gmtb2np0obvcy4"
|
||||
|
||||
got, err := hfutEncryptPassword("Lidan20051225", key)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
// 13 字节明文 → PKCS7 补齐到 16 字节 → Base64 后 24 字符
|
||||
if len(got) != 24 {
|
||||
t.Fatalf("密文长度=%d, want 24(16 字节块 Base64)", len(got))
|
||||
}
|
||||
raw, derr := base64.StdEncoding.DecodeString(got)
|
||||
if derr != nil {
|
||||
t.Fatalf("密文非合法 Base64: %v", derr)
|
||||
}
|
||||
if len(raw) != 16 {
|
||||
t.Fatalf("解出字节数=%d, want 16", len(raw))
|
||||
}
|
||||
|
||||
// 相同输入应产生相同密文(ECB 无随机 IV,确定性)
|
||||
got2, _ := hfutEncryptPassword("Lidan20051225", key)
|
||||
if got != got2 {
|
||||
t.Fatalf("ECB 加密应为确定性:%q != %q", got, got2)
|
||||
}
|
||||
|
||||
// 16 字节明文 → 再补一整块 → 32 字节 → Base64 44 字符
|
||||
got3, err := hfutEncryptPassword("abcdefghijklmnop", key)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw3, _ := base64.StdEncoding.DecodeString(got3)
|
||||
if len(raw3) != 32 {
|
||||
t.Fatalf("整块明文应补一整块,解出=%d, want 32", len(raw3))
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 key 应报错(通用 casLogin 未取 LOGIN_FLAVORING 时会命中此路径)。
|
||||
func TestHfutEncryptPasswordMissingKey(t *testing.T) {
|
||||
if _, err := hfutEncryptPassword("pwd", ""); err == nil {
|
||||
t.Fatal("缺少 key 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// hfutCasEntry 优先取 /iap 网关入口。
|
||||
func TestHfutCasEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
school *SchoolDetailInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "idsUrl 含 /iap 时取 idsUrl",
|
||||
school: &SchoolDetailInfo{IdsUrl: "https://hfut.campusphere.net/iap", WecloudUrl: "https://hfut.campusphere.net"},
|
||||
want: "https://hfut.campusphere.net/iap",
|
||||
},
|
||||
{
|
||||
name: "无 idsUrl 时由 wecloudUrl 拼 /iap",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net"},
|
||||
want: "https://hfut.campusphere.net/iap",
|
||||
},
|
||||
{
|
||||
name: "wecloudUrl 带尾斜杠",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net/"},
|
||||
want: "https://hfut.campusphere.net/iap",
|
||||
},
|
||||
{
|
||||
name: "全空回退 AmpUrl",
|
||||
school: &SchoolDetailInfo{AmpUrl: "https://hfut.campusphere.net/wec-portal-mobile/client"},
|
||||
want: "https://hfut.campusphere.net/wec-portal-mobile/client",
|
||||
},
|
||||
{name: "nil 学校", school: nil, want: ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := hfutCasEntry(c.school); got != c.want {
|
||||
t.Fatalf("hfutCasEntry=%q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,22 +117,49 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
return handleIapLoginSuccess(ctx, loginResp, school, sc)
|
||||
case 200:
|
||||
body, _ := io.ReadAll(loginResp.RawBody())
|
||||
// resultCode 在不同学校可能是数字(1/-3/-4)或字符串枚举
|
||||
// (如 "FAIL_UPNOTMATCH" 账号密码不匹配、"CAPTCHA_NOTMATCH" 验证码错误),
|
||||
// 用 RawMessage 兼容两种类型
|
||||
var result struct {
|
||||
ResultCode int `json:"resultCode"`
|
||||
ResultMsg string `json:"resultMsg"`
|
||||
ResultCode json.RawMessage `json:"resultCode"`
|
||||
ResultMsg string `json:"resultMsg"`
|
||||
NeedCaptcha bool `json:"needCaptcha"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err == nil {
|
||||
switch result.ResultCode {
|
||||
case 1:
|
||||
codeStr := strings.Trim(string(result.ResultCode), `"`)
|
||||
switch codeStr {
|
||||
case "1":
|
||||
return sc.jar.AllCookies(), nil
|
||||
case -4:
|
||||
case "-4":
|
||||
return nil, fmt.Errorf("验证码错误")
|
||||
case -3:
|
||||
case "-3":
|
||||
return nil, fmt.Errorf("账号或密码错误")
|
||||
case "FAIL_UPNOTMATCH":
|
||||
// 账号密码不匹配(实测返回格式,如哈尔滨商业大学)
|
||||
return nil, fmt.Errorf("用户名或密码不匹配,请检查后重试")
|
||||
case "CAPTCHA_NOTMATCH", "CAPTCHA_INVALID", "CAPTCHA_EXPIRED":
|
||||
return nil, fmt.Errorf("验证码错误或已过期,请重试")
|
||||
case "SUCCESS", "TRUE", "OK":
|
||||
return sc.jar.AllCookies(), nil
|
||||
}
|
||||
if result.ResultMsg != "" {
|
||||
return nil, fmt.Errorf("%s", result.ResultMsg)
|
||||
}
|
||||
if result.NeedCaptcha {
|
||||
return nil, fmt.Errorf("需要输入验证码")
|
||||
}
|
||||
slog.Warn("IAP登录未识别的resultCode",
|
||||
"school", school.Name,
|
||||
"resultCode", codeStr,
|
||||
"needCaptcha", result.NeedCaptcha,
|
||||
"body", string(body),
|
||||
)
|
||||
} else {
|
||||
slog.Warn("IAP登录响应解析失败",
|
||||
"school", school.Name,
|
||||
"error", err,
|
||||
"body", string(body),
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("IAP登录失败,未知错误")
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 辽宁工程技术大学(lntu)单独适配 ==========
|
||||
|
||||
// LntuSchoolId 辽宁工程技术大学 tenant id
|
||||
const LntuSchoolId = "lntu"
|
||||
|
||||
// lntuCasLoginEntry 计算辽宁工程技术大学 CAS 登录入口:
|
||||
// 网关域 fdm.lntu.edu.cn 的 /iap(school.IdsUrl)会 302 到学校 CAS
|
||||
// (authserver.lntu.edu.cn/authserver/login),service 参数携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
|
||||
// cookie 才能正确落在 fdm.lntu.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lntuCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// lntuCasLogin 辽宁工程技术大学强制走 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 不存在(HTTP 405),
|
||||
// IAP 账密提交必然失败并报 FAIL_UPNOTMATCH("用户名或密码不匹配",误导用户);
|
||||
// 实际链路为 IAP 入口(fdm.lntu.edu.cn/iap)302 → 学校 CAS 账密 → 回调
|
||||
// /iap/loginSuccess 建立网关会话。因此强制走 CAS 流程(同许昌学院 xcu 模式)。
|
||||
func lntuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lntu := *school
|
||||
if entry := lntuCasLoginEntry(school); entry != "" {
|
||||
lntu.AmpUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lntu, username, password)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLntuCasLoginEntry 覆盖辽宁工程技术大学 CAS 登录入口计算逻辑
|
||||
func TestLntuCasLoginEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
school *SchoolDetailInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "正常idsUrl(/iap 网关入口)",
|
||||
school: &SchoolDetailInfo{IdsUrl: "https://fdm.lntu.edu.cn/iap"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "idsUrl缺失回退wecloud+/iap",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://fdm.lntu.edu.cn"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "wecloud带尾斜杠",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://fdm.lntu.edu.cn/"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "idsUrl非iap路径回退wecloud",
|
||||
school: &SchoolDetailInfo{IdsUrl: "https://ids.lntu.edu.cn/authserver", WecloudUrl: "https://fdm.lntu.edu.cn"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "均缺失回退ampUrl",
|
||||
school: &SchoolDetailInfo{AmpUrl: "https://fdm.lntu.edu.cn/wec-portal-mobile/client"},
|
||||
want: "https://fdm.lntu.edu.cn/wec-portal-mobile/client",
|
||||
},
|
||||
{
|
||||
name: "均为空",
|
||||
school: &SchoolDetailInfo{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil学校",
|
||||
school: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := lntuCasLoginEntry(c.school); got != c.want {
|
||||
t.Errorf("lntuCasLoginEntry() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLntuCasLoginEntryOverridesAmpUrl 确认 lntuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
|
||||
func TestLntuCasLoginEntryOverridesAmpUrl(t *testing.T) {
|
||||
school := &SchoolDetailInfo{
|
||||
ID: LntuSchoolId,
|
||||
Name: "辽宁工程技术大学",
|
||||
JoinType: "CLOUD", // 平台标记为 CLOUD,但 IAP 不可用,需强制 CAS
|
||||
IdsUrl: "https://fdm.lntu.edu.cn/iap",
|
||||
WecloudUrl: "https://fdm.lntu.edu.cn",
|
||||
AmpUrl: "https://fdm.lntu.edu.cn/wec-portal-mobile/client",
|
||||
}
|
||||
// 模拟 lntuCasLogin 内部的入口覆盖(不发起网络请求)
|
||||
clone := *school
|
||||
clone.AmpUrl = lntuCasLoginEntry(school)
|
||||
if got := getCasValidLoginUrl(&clone); got != "https://fdm.lntu.edu.cn/iap" {
|
||||
t.Errorf("getCasValidLoginUrl() = %q, want 网关 /iap 入口", got)
|
||||
}
|
||||
// 原对象不被修改
|
||||
if school.AmpUrl != "https://fdm.lntu.edu.cn/wec-portal-mobile/client" {
|
||||
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLntuIsSliderCaptchaSchool 确认 lntu 归入滑块验证码学校需通过配置声明
|
||||
// (captchaSwitch=2 + createSliderCaptcha → /common/toSliderCaptcha.htl,与 hqdx 同协议)
|
||||
func TestLntuSliderProtocolSameAsHqu(t *testing.T) {
|
||||
// 协议一致性由配置驱动:lntu 与 hqdx 共用 resolveWiseduSliderCaptcha
|
||||
//(openSliderCaptcha.htl 取图 → 打码 → verifySliderCaptcha.htl sign 提交)。
|
||||
// 此处仅断言常量存在且不与其他学校 ID 冲突。
|
||||
if LntuSchoolId == "" || LntuSchoolId != "lntu" {
|
||||
t.Errorf("LntuSchoolId = %q, want lntu", LntuSchoolId)
|
||||
}
|
||||
if HquSchoolId == LntuSchoolId {
|
||||
t.Errorf("lntu 与 hqdx 学校 ID 冲突")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 兰州理工大学(lut)单独适配 ==========
|
||||
|
||||
// LutSchoolId 兰州理工大学 tenant id
|
||||
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
|
||||
|
||||
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
|
||||
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
|
||||
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lutCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.AmpUrl != "" {
|
||||
return school.AmpUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.IdsUrl
|
||||
}
|
||||
|
||||
// lutCasLogin 兰州理工大学强制走 CAS:
|
||||
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
|
||||
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
|
||||
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
|
||||
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
|
||||
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
|
||||
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lut := *school
|
||||
if entry := lutCasLoginEntry(school); entry != "" {
|
||||
lut.AmpUrl = entry
|
||||
lut.IdsUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lut, username, password)
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS 多因子认证(MFA / reAuth)==========
|
||||
|
||||
// 今日校园定制 CAS 的多因子认证扩展(reAuthCheck)。
|
||||
// 触发场景:CAS 账密提交成功后不是直接签发 ST 票据,而是 302 到
|
||||
// {base}/authserver/reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=<encoded>
|
||||
// 该页面渲染当前用户全部可用的二次认证方式(Tab / 更多菜单 / 旧版列表),
|
||||
// 调用方应把方式列表返回给用户选择;用户选定后调用发码接口(按需先 changeReAuthType.do
|
||||
// 切换服务端会话方式),用户取得验证码后再提交 reAuthSubmit.do 完成认证,
|
||||
// 最后访问 /login?service= 完成票据交换。
|
||||
|
||||
// ErrMFARequired 表示本次登录触发了多因子认证,
|
||||
// 调用方应为返回的 MFAContext 暂存并引导用户选择验证方式、获取验证码并提交。
|
||||
var ErrMFARequired = fmt.Errorf("mfa_required")
|
||||
|
||||
// ErrMFASessionExpired 表示 MFA 中间会话已失效(Redis 过期 / reAuth_unauthorized / 会话被服务端作废),
|
||||
// 调用方应引导用户重新登录(可用 errors.Is 判断)。
|
||||
var ErrMFASessionExpired = fmt.Errorf("mfa_session_expired")
|
||||
|
||||
// MFAAuthMethod 一种可用的多因子验证方式(来自 MFA 页面渲染的可用方式列表)
|
||||
type MFAAuthMethod struct {
|
||||
Type string `json:"type"` // reAuthType 数字编码(3=短信 4=企业微信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号)
|
||||
Name string `json:"name"` // 展示名(优先页面文案,缺省用内置映射)
|
||||
Default bool `json:"default,omitempty"` // 页面默认选中项
|
||||
}
|
||||
|
||||
// MFAContext 触发 MFA 后的中间会话状态(可 JSON 序列化存 Redis,TTL 内有效)
|
||||
type MFAContext struct {
|
||||
BaseURL string `json:"baseUrl"` // CAS 根地址,如 http://ids.hfpec.edu.cn
|
||||
ContextPath string `json:"contextPath"` // CAS 上下文路径,默认 /authserver
|
||||
Service string `json:"service"` // reAuth service(含 sessionToken)
|
||||
ReAuthType string `json:"reAuthType"` // 当前/用户最后选定的方式:2=密码 3=短信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号
|
||||
UserID string `json:"userId"` // 学工号
|
||||
AuthMethods []MFAAuthMethod `json:"authMethods,omitempty"` // 页面支持的全部可用验证方式
|
||||
WecloudUrl string `json:"wecloudUrl"` // 完成登录后 activateSession 所需
|
||||
CookiesByHost map[string][]*http.Cookie `json:"cookiesByHost"` // CAS 阶段会话(按域保存)
|
||||
}
|
||||
|
||||
// MFASendResult 发送 MFA 验证码的结果
|
||||
type MFASendResult struct {
|
||||
Mobile string `json:"mobile,omitempty"` // 收码目标(手机号等,可能带掩码)
|
||||
CodeTime int `json:"codeTime,omitempty"` // 可再次发送的倒计时秒数(0 表示不限制/未提供)
|
||||
Message string `json:"message,omitempty"` // 服务端提示文案
|
||||
}
|
||||
|
||||
// reAuthType → 发码接口的 authCodeTypeName 映射(源自 mobile-reAuth.js sendDynamicCodeByPhone)
|
||||
var reAuthCodeTypeNames = map[string]string{
|
||||
"3": "reAuthDynamicCodeType", // 手机短信
|
||||
"4": "reAuthWChatDynamicCodeType", // 企业微信
|
||||
"5": "reAuthCpdailyDynamicCodeType", // 今日校园
|
||||
"11": "reAuthEmailDynamicCodeType", // 邮箱
|
||||
"12": "reAuthDingTalkDynamicCodeType", // 钉钉
|
||||
"13": "reAuthWeLinkDynamicCodeType", // WeLink
|
||||
"15": "reAuthWeChatServiceDynamicCodeType", // 微信公众号
|
||||
}
|
||||
|
||||
// reAuthType → 默认展示名(页面 Tab 无文案时兜底)
|
||||
var reAuthTypeNames = map[string]string{
|
||||
"2": "密码验证",
|
||||
"3": "短信验证码",
|
||||
"4": "企业微信验证码",
|
||||
"5": "今日校园验证码",
|
||||
"6": "实人认证",
|
||||
"7": "安全问题",
|
||||
"9": "QQ扫码",
|
||||
"10": "安全令牌OTP",
|
||||
"11": "邮箱验证",
|
||||
"12": "钉钉验证码",
|
||||
"13": "WeLink验证码",
|
||||
"15": "公众号验证码",
|
||||
}
|
||||
|
||||
// 需要发送动态码的 reAuthType 集合(密码/人脸/密保问题等类型不走发码接口)
|
||||
func reAuthTypeNeedsDynamicCode(reAuthType string) bool {
|
||||
_, ok := reAuthCodeTypeNames[reAuthType]
|
||||
return ok
|
||||
}
|
||||
|
||||
var (
|
||||
reAuthParamsBlockRegexp = regexp.MustCompile(`var\s+reAuthParams\s*=\s*\{([\s\S]*?)\n\s*\}`)
|
||||
reAuthTypeRegexp = regexp.MustCompile(`"reAuthType"\s*:\s*"([^"]*)"`)
|
||||
reAuthServiceRegexp = regexp.MustCompile(`"service"\s*:\s*"([^"]*)"`)
|
||||
reAuthUserIdRegexp = regexp.MustCompile(`"reAuthUserId"\s*:\s*"([^"]*)"`)
|
||||
contextPathRegexp = regexp.MustCompile(`contextPath\s*=\s*"([^"]*)"`)
|
||||
trailingDigitsRegexp = regexp.MustCompile(`(\d+)$`)
|
||||
)
|
||||
|
||||
// 页面可用验证方式选择器(兼容历代模板;注释块内的元素不会被 goquery 匹配):
|
||||
// .mobile-reauth-tab-item[data-type] 移动端 Tab(如 id="mobile_tab_5")
|
||||
// .reauth-tab-item[data-type] 新版统一模板 Tab(如 id="tab_13")
|
||||
// .reauth-tab-more-item[id] "更多"菜单(id 即编号)
|
||||
// .changeReAuthTypes[id] 旧版列表(id 即编号)
|
||||
// .other_type_button[id] "选择认证方式"弹窗按钮(如许昌学院 xcuTheme,id 即编号)
|
||||
const mfaMethodSelectors = ".mobile-reauth-tab-item, .reauth-tab-item, .reauth-tab-more-item, .changeReAuthTypes, .other_type_button"
|
||||
|
||||
// parseMFAAuthMethods 从 MFA 页面解析全部可用验证方式(返回页面渲染顺序)
|
||||
func parseMFAAuthMethods(page string) []MFAAuthMethod {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(page))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var methods []MFAAuthMethod
|
||||
seen := make(map[string]bool)
|
||||
doc.Find(mfaMethodSelectors).Each(func(_ int, s *goquery.Selection) {
|
||||
reAuthType := strings.TrimSpace(s.AttrOr("data-type", ""))
|
||||
if reAuthType == "" {
|
||||
// 无 data-type 时从 id 提取尾部数字:mobile_tab_5 / tab_13 / 5 均可
|
||||
reAuthType = trailingDigitsRegexp.FindString(s.AttrOr("id", ""))
|
||||
}
|
||||
if reAuthType == "" || seen[reAuthType] {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(s.Find(".mobile-reauth-tab-text").Text())
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.Text())
|
||||
}
|
||||
if name == "" {
|
||||
name = reAuthTypeNames[reAuthType]
|
||||
}
|
||||
if name == "" {
|
||||
name = "验证方式" + reAuthType
|
||||
}
|
||||
seen[reAuthType] = true
|
||||
methods = append(methods, MFAAuthMethod{
|
||||
Type: reAuthType,
|
||||
Name: name,
|
||||
Default: s.HasClass("mobile-reauth-tab-active") || s.HasClass("reauth-tab-active"),
|
||||
})
|
||||
})
|
||||
return methods
|
||||
}
|
||||
|
||||
// normalizeMFAAuthMethods 规范化可用验证方式列表:
|
||||
// 1. 仅保留能接收动态码、本流程可完成的方式(短信/企业微信/今日校园/邮箱/钉钉/WeLink/公众号);
|
||||
// 2. 过滤后为空时保留原列表(页面异常时前端仍能看到可选方式,发码时会得到明确提示);
|
||||
// 3. 列表整体为空(页面无 Tab / 解析失败)且当前方式支持动态码时,用当前方式兜底为唯一选项,
|
||||
// 避免前端「选择验证方式」无项可选、流程走不通;
|
||||
// 4. 页面未标记默认项时,按 defaultType 标记,最后兜底标记首项。
|
||||
func normalizeMFAAuthMethods(methods []MFAAuthMethod, defaultType string) []MFAAuthMethod {
|
||||
var supported []MFAAuthMethod
|
||||
for _, m := range methods {
|
||||
if reAuthTypeNeedsDynamicCode(m.Type) {
|
||||
supported = append(supported, m)
|
||||
}
|
||||
}
|
||||
if len(supported) > 0 {
|
||||
methods = supported
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
if !reAuthTypeNeedsDynamicCode(defaultType) {
|
||||
return methods
|
||||
}
|
||||
// 页面无 Tab(解析失败或旧版模板):用当前 reAuthType 兜底为唯一方式
|
||||
name := reAuthTypeNames[defaultType]
|
||||
if name == "" {
|
||||
name = "验证方式" + defaultType
|
||||
}
|
||||
return []MFAAuthMethod{{Type: defaultType, Name: name, Default: true}}
|
||||
}
|
||||
if !hasDefaultMFAMethod(methods) {
|
||||
for i := range methods {
|
||||
if methods[i].Type == defaultType {
|
||||
methods[i].Default = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasDefaultMFAMethod(methods) {
|
||||
methods[0].Default = true
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// hasDefaultMFAMethod 列表内是否已有默认项
|
||||
func hasDefaultMFAMethod(methods []MFAAuthMethod) bool {
|
||||
for _, m := range methods {
|
||||
if m.Default {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleMFATriggered 处理 CAS 登录后 302 到 MFA 页面的情况:
|
||||
// 解析 MFA 页面参数与全部可用验证方式,返回 MFAContext 供用户选择方式、发送并提交验证码。
|
||||
func handleMFATriggered(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) (*MFAContext, error) {
|
||||
location, err := resp.RawResponse.Location()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取MFA重定向地址失败: %w", err)
|
||||
}
|
||||
|
||||
// 恢复自动重定向(后续请求页面需要跟随)
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(location.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求MFA页面失败: %w", err)
|
||||
}
|
||||
page := string(readBody(pageResp))
|
||||
slog.Info("MFA页面获取", "url", location.String(), "bodyLen", len(page))
|
||||
|
||||
mfa := &MFAContext{
|
||||
WecloudUrl: school.WecloudUrl,
|
||||
}
|
||||
|
||||
// CAS 根地址与上下文路径
|
||||
if u, err := url.Parse(location.String()); err == nil {
|
||||
mfa.BaseURL = u.Scheme + "://" + u.Host
|
||||
}
|
||||
mfa.ContextPath = "/authserver"
|
||||
if m := contextPathRegexp.FindStringSubmatch(page); len(m) > 1 {
|
||||
mfa.ContextPath = strings.ReplaceAll(m[1], `\/`, "/")
|
||||
}
|
||||
|
||||
// 解析 reAuthParams 块内的关键字段
|
||||
block := reAuthParamsBlockRegexp.FindStringSubmatch(page)
|
||||
if len(block) > 1 {
|
||||
params := block[1]
|
||||
if m := reAuthTypeRegexp.FindStringSubmatch(params); len(m) > 1 {
|
||||
mfa.ReAuthType = m[1]
|
||||
}
|
||||
if m := reAuthServiceRegexp.FindStringSubmatch(params); len(m) > 1 {
|
||||
mfa.Service = strings.ReplaceAll(m[1], `\/`, "/")
|
||||
}
|
||||
if m := reAuthUserIdRegexp.FindStringSubmatch(params); len(m) > 1 {
|
||||
mfa.UserID = m[1]
|
||||
}
|
||||
}
|
||||
// 兜底:service 从 302 Location 的 query 取
|
||||
if mfa.Service == "" {
|
||||
mfa.Service = location.Query().Get("service")
|
||||
}
|
||||
if mfa.ReAuthType == "" {
|
||||
mfa.ReAuthType = "5"
|
||||
slog.Warn("MFA页面未解析到reAuthType,按动态码处理", "base", mfa.BaseURL)
|
||||
}
|
||||
|
||||
// 解析页面全部可用验证方式(Tab / 更多菜单 / 旧版列表)
|
||||
mfa.AuthMethods = normalizeMFAAuthMethods(parseMFAAuthMethods(page), mfa.ReAuthType)
|
||||
slog.Info("MFA参数解析", "reAuthType", mfa.ReAuthType, "userId", mfa.UserID,
|
||||
"service", mfa.Service, "methods", len(mfa.AuthMethods))
|
||||
|
||||
// 不再自动发送验证码:由用户在前端选择验证方式后调用 SendMFADynamicCode 发送
|
||||
if !reAuthTypeNeedsDynamicCode(mfa.ReAuthType) {
|
||||
slog.Warn("MFA默认验证方式非动态码类型,需用户选择其他方式", "reAuthType", mfa.ReAuthType)
|
||||
}
|
||||
|
||||
// 保存当前 CAS 会话(按域)
|
||||
mfa.CookiesByHost = sc.jar.AllCookiesByHost()
|
||||
|
||||
return mfa, nil
|
||||
}
|
||||
|
||||
// restoreMFACasSession 把触发 MFA 时保存的 CAS 会话恢复到新的会话客户端
|
||||
func restoreMFACasSession(sc *sessionClient, mfa *MFAContext) {
|
||||
for host, cookies := range mfa.CookiesByHost {
|
||||
u := &url.URL{Scheme: "http", Host: host}
|
||||
if strings.HasPrefix(mfa.BaseURL, "https://") {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
sc.jar.SetCookies(u, cookies)
|
||||
}
|
||||
}
|
||||
|
||||
// changeMFAAuthType 调用 changeReAuthType.do 把服务端 MFA 会话切换到指定验证方式
|
||||
// POST {base}{contextPath}/reAuthCheck/changeReAuthType.do
|
||||
func changeMFAAuthType(ctx context.Context, client *resty.Client, mfa *MFAContext, reAuthType string) error {
|
||||
changeUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/changeReAuthType.do"
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"isMultifactor": "true",
|
||||
"reAuthType": reAuthType,
|
||||
"service": mfa.Service,
|
||||
}).
|
||||
Post(changeUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("切换MFA验证方式失败: %w", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
slog.Info("MFA切换验证方式响应", "status", resp.StatusCode(), "reAuthType", reAuthType,
|
||||
"body", string(body[:min(len(body), 500)]))
|
||||
|
||||
// code 兼容数字 1 与字符串 "1"(不同学校版本返回类型不一致)
|
||||
var result struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("切换MFA验证方式响应解析失败: %w", err)
|
||||
}
|
||||
if fmt.Sprintf("%v", result.Code) != "1" {
|
||||
return fmt.Errorf("%s", orDefault(result.Message, "该验证方式暂不可用,请选择其他方式"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendReAuthDynamicCodeResult 发码接口响应
|
||||
type sendReAuthDynamicCodeResult struct {
|
||||
Res string `json:"res"` // success/cpdaily_success/wechat_success/other_success/code_time_fail
|
||||
ReturnMessage string `json:"returnMessage"` // 服务端提示文案
|
||||
Mobile string `json:"mobile"` // 收码目标(手机号等)
|
||||
CodeTime int `json:"codeTime"` // 可再次发送的倒计时秒数
|
||||
ErrCode string `json:"errCode"` // 错误码(如 206302 会话失效)
|
||||
}
|
||||
|
||||
// postReAuthDynamicCode 调用 CAS 发码接口发送验证码
|
||||
// POST {base}{contextPath}/dynamicCode/getDynamicCodeByReauth.do
|
||||
func postReAuthDynamicCode(ctx context.Context, client *resty.Client, mfa *MFAContext, authCodeTypeName string) (*sendReAuthDynamicCodeResult, error) {
|
||||
sendUrl := mfa.BaseURL + mfa.ContextPath + "/dynamicCode/getDynamicCodeByReauth.do"
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"userName": mfa.UserID,
|
||||
"authCodeTypeName": authCodeTypeName,
|
||||
}).
|
||||
Post(sendUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送MFA验证码失败: %w", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
slog.Info("MFA验证码发送响应", "status", resp.StatusCode(), "body", string(body[:min(len(body), 500)]))
|
||||
|
||||
var result sendReAuthDynamicCodeResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("发送MFA验证码响应解析失败: %w", err)
|
||||
}
|
||||
// 206302:服务端 MFA 会话已失效
|
||||
if result.ErrCode == "206302" {
|
||||
return nil, fmt.Errorf("%w: 验证会话已失效,请重新登录", ErrMFASessionExpired)
|
||||
}
|
||||
|
||||
switch result.Res {
|
||||
case "success", "cpdaily_success", "wechat_success", "other_success", "code_time_fail":
|
||||
// 发送成功(code_time_fail 为倒计时未结束,验证码此前已发出)
|
||||
return &result, nil
|
||||
default:
|
||||
msg := result.ReturnMessage
|
||||
if msg == "" {
|
||||
msg = "发送验证码失败,请稍后重试"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMFADynamicCode 用户选定验证方式后发送动态验证码:
|
||||
// 与当前方式不同时先调用 changeReAuthType.do 切换服务端会话,再调用发码接口;
|
||||
// 成功后同步 mfa.ReAuthType,供后续 reAuthSubmit.do 提交使用。
|
||||
func SendMFADynamicCode(ctx context.Context, mfa *MFAContext, reAuthType string) (*MFASendResult, error) {
|
||||
if mfa == nil || len(mfa.CookiesByHost) == 0 {
|
||||
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
|
||||
}
|
||||
if reAuthType == "" {
|
||||
reAuthType = mfa.ReAuthType
|
||||
}
|
||||
if len(mfa.AuthMethods) > 0 && !mfaHasAuthMethod(mfa.AuthMethods, reAuthType) {
|
||||
return nil, fmt.Errorf("不支持的验证方式,请选择其他方式")
|
||||
}
|
||||
if !reAuthTypeNeedsDynamicCode(reAuthType) {
|
||||
name := reAuthTypeNames[reAuthType]
|
||||
if name == "" {
|
||||
name = "验证方式" + reAuthType
|
||||
}
|
||||
return nil, fmt.Errorf("「%s」暂不支持发送验证码,请选择其他方式", name)
|
||||
}
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sc := newSessionClient()
|
||||
restoreMFACasSession(sc, mfa)
|
||||
|
||||
// 与当前会话方式不同时先切换(与官方页面 Tab 切换行为一致)
|
||||
if reAuthType != mfa.ReAuthType {
|
||||
if err := changeMFAAuthType(loginCtx, sc.Client, mfa, reAuthType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfa.ReAuthType = reAuthType
|
||||
slog.Info("MFA验证方式已切换", "reAuthType", reAuthType)
|
||||
}
|
||||
|
||||
result, err := postReAuthDynamicCode(loginCtx, sc.Client, mfa, reAuthCodeTypeNames[reAuthType])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MFASendResult{
|
||||
Mobile: result.Mobile,
|
||||
CodeTime: result.CodeTime,
|
||||
Message: orDefault(result.ReturnMessage, "验证码已发送"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mfaHasAuthMethod 判断方式编号是否在可用列表中
|
||||
func mfaHasAuthMethod(methods []MFAAuthMethod, reAuthType string) bool {
|
||||
for _, m := range methods {
|
||||
if m.Type == reAuthType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CompleteMFALogin 提交用户输入的验证码,完成 MFA 认证并走完 CAS 票据交换,
|
||||
// 返回登录成功后的全部 cookies。
|
||||
func CompleteMFALogin(ctx context.Context, mfa *MFAContext, code string) ([]*http.Cookie, error) {
|
||||
if mfa == nil || len(mfa.CookiesByHost) == 0 {
|
||||
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
|
||||
}
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sc := newSessionClient()
|
||||
// 恢复 CAS 阶段会话
|
||||
restoreMFACasSession(sc, mfa)
|
||||
|
||||
// 1. 提交验证码(reAuthSubmit.do 返回 JSON,不涉及重定向)
|
||||
submitUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/reAuthSubmit.do"
|
||||
submitResp, err := sc.R().SetContext(loginCtx).
|
||||
SetFormData(map[string]string{
|
||||
"service": mfa.Service,
|
||||
"reAuthType": mfa.ReAuthType,
|
||||
"isMultifactor": "true",
|
||||
"password": "",
|
||||
"dynamicCode": code,
|
||||
"uuid": "",
|
||||
"answer1": "",
|
||||
"answer2": "",
|
||||
"otpCode": "",
|
||||
}).
|
||||
Post(submitUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("提交MFA验证码失败: %w", err)
|
||||
}
|
||||
body := readBody(submitResp)
|
||||
slog.Info("MFA验证码提交响应", "status", submitResp.StatusCode(), "body", string(body[:min(len(body), 500)]))
|
||||
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
switch result.Code {
|
||||
case "reAuth_failed":
|
||||
return nil, fmt.Errorf("%s", orDefault(result.Msg, "验证码错误或已过期"))
|
||||
case "reAuth_unauthorized":
|
||||
return nil, fmt.Errorf("%w: %s", ErrMFASessionExpired, orDefault(result.Msg, "验证会话已失效,请重新登录"))
|
||||
}
|
||||
|
||||
// 2. 完成 CAS 票据交换:GET /login?service=... → 302(service?ticket=) → wecloud /iap/loginSuccess
|
||||
exchangeUrl := mfa.BaseURL + mfa.ContextPath + "/login?service=" + url.QueryEscape(mfa.Service)
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
exchangeResp, err := sc.R().SetContext(loginCtx).Get(exchangeUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MFA票据交换失败: %w", err)
|
||||
}
|
||||
_ = exchangeResp
|
||||
|
||||
// 3. 激活 wecloud 会话
|
||||
activateSession(loginCtx, mfa.WecloudUrl, sc.Client)
|
||||
|
||||
cookies := sc.jar.AllCookies()
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// orDefault 空串时取默认值
|
||||
func orDefault(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ========== MFA 页面「可用验证方式」解析测试 ==========
|
||||
|
||||
// 移动端模板样本(结构取自真实 MFA 页面,已脱敏)
|
||||
const mfaMobilePageSample = `<!DOCTYPE html><html><body>
|
||||
<script>var reAuthParams = {
|
||||
"reAuthType":"5",
|
||||
"isMultifactor":"true",
|
||||
"service":"https:\/\/school.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
|
||||
"reAuthUserId":"20220000000"
|
||||
};</script>
|
||||
<div class="mobile-reauth-tab-list" id="mobileTabList">
|
||||
<div class="mobile-reauth-tab-item mobile-reauth-tab-active" id="mobile_tab_5"
|
||||
onclick="handleMobileTabClick(this)" data-type="5" data-index="0">
|
||||
<span class="mobile-reauth-tab-text" title="今日校园验证码">今日校园验证码</span>
|
||||
</div>
|
||||
<div class="mobile-reauth-tab-item" id="mobile_tab_11" onclick="handleMobileTabClick(this)" data-type="11" data-index="1">
|
||||
<span class="mobile-reauth-tab-text" title="邮箱验证">邮箱验证</span>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
func TestParseMFAAuthMethodsMobileTabs(t *testing.T) {
|
||||
methods := parseMFAAuthMethods(mfaMobilePageSample)
|
||||
if len(methods) != 2 {
|
||||
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "5" || methods[0].Name != "今日校园验证码" || !methods[0].Default {
|
||||
t.Errorf("第 1 项解析错误: %+v", methods[0])
|
||||
}
|
||||
if methods[1].Type != "11" || methods[1].Name != "邮箱验证" || methods[1].Default {
|
||||
t.Errorf("第 2 项解析错误: %+v", methods[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsNewLayoutWithMoreMenu(t *testing.T) {
|
||||
page := `<html><body>
|
||||
<div class="reauth-tab-item reauth-tab-active" id="tab_13" data-type="13"></div>
|
||||
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
|
||||
<a class="reauth-tab-more-item" id="10">OTP</a>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 3 {
|
||||
t.Fatalf("期望解析出 3 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "13" || !methods[0].Default || methods[0].Name != "WeLink验证码" {
|
||||
t.Errorf("第 1 项解析错误(应取 data-type、active 标记、兜底名): %+v", methods[0])
|
||||
}
|
||||
if methods[1].Type != "3" || methods[1].Name != "短信验证码" {
|
||||
t.Errorf("第 2 项解析错误: %+v", methods[1])
|
||||
}
|
||||
if methods[2].Type != "10" || methods[2].Name != "OTP" {
|
||||
t.Errorf("更多菜单项解析错误(id 即编号、文本即名字): %+v", methods[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsLegacyLayout(t *testing.T) {
|
||||
page := `<html><body>
|
||||
<div class="changeReAuthTypes" id="3"></div>
|
||||
<div class="changeReAuthTypes" id="10"></div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 2 {
|
||||
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "3" || methods[1].Type != "10" {
|
||||
t.Errorf("旧版列表解析错误: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
// 许昌学院 xcuTheme:「选择认证方式」弹窗(结构取自真实 MFA 页面,已脱敏)
|
||||
const mfaOtherTypeDialogPageSample = `<html><body>
|
||||
<script>var reAuthParams = {
|
||||
"reAuthType":"3",
|
||||
"isMultifactor":"true",
|
||||
"service":"https:\/\/xcu.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
|
||||
"reAuthUserId":"5810260823"
|
||||
};</script>
|
||||
<div id="other_dialog">
|
||||
<div class="mask changeOtherType"></div>
|
||||
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
|
||||
<div class="other_type_title other_type_header_title">选择认证方式</div>
|
||||
<div id="3" class="other_type_button" onclick="changeMobileReAuthType(this)">短信验证码</div>
|
||||
<div class="other_type_title cancel_bottom_btn" onclick="mobileChangeTypeCancel();">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
func TestParseMFAAuthMethodsOtherTypeDialog(t *testing.T) {
|
||||
methods := parseMFAAuthMethods(mfaOtherTypeDialogPageSample)
|
||||
if len(methods) != 1 {
|
||||
t.Fatalf("弹窗方式列表应解析出 1 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "3" || methods[0].Name != "短信验证码" {
|
||||
t.Errorf("弹窗方式解析错误(id 即编号、文本即名字): %+v", methods[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsOtherTypeDialogMulti(t *testing.T) {
|
||||
// 多方式弹窗:标题不应被误解析,仅 .other_type_button 计入
|
||||
page := `<html><body>
|
||||
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
|
||||
<div class="other_type_title other_type_header_title">选择认证方式</div>
|
||||
<div id="3" class="other_type_button">短信验证码</div>
|
||||
<div id="11" class="other_type_button">邮箱验证</div>
|
||||
<div class="other_type_title cancel_bottom_btn">取消</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 2 {
|
||||
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "3" || methods[0].Name != "短信验证码" || methods[1].Type != "11" || methods[1].Name != "邮箱验证" {
|
||||
t.Errorf("多方式弹窗解析错误: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsDedupeAcrossLayouts(t *testing.T) {
|
||||
// 同一方式同时出现在 Tab 与弹窗时应去重(保留先出现项)
|
||||
page := `<html><body>
|
||||
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
|
||||
<div class="other_type_button" id="3">短信验证码</div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 1 || methods[0].Type != "3" {
|
||||
t.Fatalf("跨布局重复方式应去重,实际: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsIgnoresComments(t *testing.T) {
|
||||
page := `<html><body>
|
||||
<!-- <div class="mobile-reauth-tab-item" data-type="9"><span class="mobile-reauth-tab-text">QQ扫码</span></div> -->
|
||||
<div class="mobile-reauth-tab-item" data-type="5"><span class="mobile-reauth-tab-text">今日校园验证码</span></div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 1 || methods[0].Type != "5" {
|
||||
t.Fatalf("注释块内的方式不应被解析,实际: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsEmptyPage(t *testing.T) {
|
||||
if methods := parseMFAAuthMethods("<html><body><p>no tabs</p></body></html>"); len(methods) != 0 {
|
||||
t.Fatalf("无方式列表的页面应返回空,实际: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 方式列表规范化测试 ==========
|
||||
|
||||
func TestNormalizeMFAAuthMethodsFiltersNonDynamicCode(t *testing.T) {
|
||||
in := []MFAAuthMethod{
|
||||
{Type: "2", Name: "密码验证"},
|
||||
{Type: "5", Name: "今日校园验证码"},
|
||||
{Type: "11", Name: "邮箱验证"},
|
||||
{Type: "10", Name: "安全令牌OTP"},
|
||||
}
|
||||
out := normalizeMFAAuthMethods(in, "5")
|
||||
if len(out) != 2 || out[0].Type != "5" || out[1].Type != "11" {
|
||||
t.Fatalf("应过滤到仅动态码方式 [5,11],实际: %+v", out)
|
||||
}
|
||||
if !out[0].Default || out[1].Default {
|
||||
t.Errorf("默认项标记错误: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMFAAuthMethodsKeepsAllWhenNoDynamicCode(t *testing.T) {
|
||||
in := []MFAAuthMethod{
|
||||
{Type: "2", Name: "密码验证"},
|
||||
{Type: "6", Name: "实人认证"},
|
||||
}
|
||||
out := normalizeMFAAuthMethods(in, "2")
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("全为非动态码时应保留原列表,实际: %+v", out)
|
||||
}
|
||||
if !out[0].Default {
|
||||
t.Errorf("应按 defaultType 标记默认项: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMFAAuthMethodsDefaultFallbacks(t *testing.T) {
|
||||
// 页面未标记默认 + defaultType 匹配第二项
|
||||
in := []MFAAuthMethod{
|
||||
{Type: "5", Name: "Today"},
|
||||
{Type: "11", Name: "Email"},
|
||||
}
|
||||
out := normalizeMFAAuthMethods(in, "11")
|
||||
if !out[1].Default || out[0].Default {
|
||||
t.Fatalf("defaultType=11 应标记第二项: %+v", out)
|
||||
}
|
||||
// defaultType 不在列表时标记首项
|
||||
in2 := []MFAAuthMethod{
|
||||
{Type: "5", Name: "Today"},
|
||||
{Type: "3", Name: "SMS"},
|
||||
}
|
||||
out2 := normalizeMFAAuthMethods(in2, "9")
|
||||
if !out2[0].Default {
|
||||
t.Fatalf("defaultType 不在列表时应标记首项: %+v", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMFAAuthMethodsEmptyListFallback(t *testing.T) {
|
||||
// 页面无 Tab / 解析失败且当前方式支持动态码:用当前方式兜底为唯一选项,避免前端无可选
|
||||
out := normalizeMFAAuthMethods(nil, "5")
|
||||
if len(out) != 1 || out[0].Type != "5" || out[0].Name != "今日校园验证码" || !out[0].Default {
|
||||
t.Fatalf("空列表应按 reAuthType=5 兜底为唯一选项,实际: %+v", out)
|
||||
}
|
||||
// 当前方式不支持动态码(如实人认证)时不造选项,保持空
|
||||
if out := normalizeMFAAuthMethods(nil, "6"); len(out) != 0 {
|
||||
t.Fatalf("非动态码方式不应兜底,实际: %+v", out)
|
||||
}
|
||||
// defaultType 为空同样不猜测
|
||||
if out := normalizeMFAAuthMethods(nil, ""); len(out) != 0 {
|
||||
t.Fatalf("defaultType 为空不应兜底,实际: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SendMFADynamicCode 全流程测试(mock CAS 服务器) ==========
|
||||
|
||||
func TestSendMFADynamicCode(t *testing.T) {
|
||||
var (
|
||||
changeCalled bool
|
||||
changeForm map[string]string
|
||||
codeForm map[string]string
|
||||
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
|
||||
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/authserver/reAuthCheck/changeReAuthType.do":
|
||||
changeCalled = true
|
||||
_ = r.ParseForm()
|
||||
changeForm = map[string]string{
|
||||
"isMultifactor": r.Form.Get("isMultifactor"),
|
||||
"reAuthType": r.Form.Get("reAuthType"),
|
||||
"service": r.Form.Get("service"),
|
||||
}
|
||||
_, _ = w.Write([]byte(changeResp))
|
||||
case "/authserver/dynamicCode/getDynamicCodeByReauth.do":
|
||||
_ = r.ParseForm()
|
||||
codeForm = map[string]string{
|
||||
"userName": r.Form.Get("userName"),
|
||||
"authCodeTypeName": r.Form.Get("authCodeTypeName"),
|
||||
}
|
||||
_, _ = w.Write([]byte(codeResp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
newMFA := func() *MFAContext {
|
||||
return &MFAContext{
|
||||
BaseURL: server.URL,
|
||||
ContextPath: "/authserver",
|
||||
Service: "https://school.campusphere.net/iap/loginSuccess?sessionToken=t",
|
||||
ReAuthType: "5",
|
||||
UserID: "20220000000",
|
||||
AuthMethods: []MFAAuthMethod{
|
||||
{Type: "5", Name: "今日校园验证码", Default: true},
|
||||
{Type: "11", Name: "邮箱验证"},
|
||||
},
|
||||
CookiesByHost: map[string][]*http.Cookie{
|
||||
host: {{Name: "CASTGC", Value: "TGT-test"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
reset := func() {
|
||||
changeCalled = false
|
||||
changeForm = nil
|
||||
codeForm = nil
|
||||
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
|
||||
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
|
||||
}
|
||||
|
||||
t.Run("切换到新方式并发送", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
result, err := SendMFADynamicCode(context.Background(), mfa, "11")
|
||||
if err != nil {
|
||||
t.Fatalf("发送失败: %v", err)
|
||||
}
|
||||
if !changeCalled {
|
||||
t.Error("选择了与默认不同的方式,应先调用 changeReAuthType.do")
|
||||
}
|
||||
if changeForm["isMultifactor"] != "true" || changeForm["reAuthType"] != "11" || changeForm["service"] != mfa.Service {
|
||||
t.Errorf("切换参数错误: %+v", changeForm)
|
||||
}
|
||||
if codeForm["authCodeTypeName"] != "reAuthEmailDynamicCodeType" || codeForm["userName"] != "20220000000" {
|
||||
t.Errorf("发码参数错误: %+v", codeForm)
|
||||
}
|
||||
if result.Mobile != "138****0000" || result.CodeTime != 120 || result.Message != "验证码已发送成功!" {
|
||||
t.Errorf("发码结果解析错误: %+v", result)
|
||||
}
|
||||
if mfa.ReAuthType != "11" {
|
||||
t.Errorf("发送成功后应同步 ReAuthType=11,实际 %s", mfa.ReAuthType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("沿用默认方式不切换", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if err != nil {
|
||||
t.Fatalf("发送失败: %v", err)
|
||||
}
|
||||
if changeCalled {
|
||||
t.Error("选择与默认相同的方式不应调用 changeReAuthType.do")
|
||||
}
|
||||
if codeForm["authCodeTypeName"] != "reAuthCpdailyDynamicCodeType" {
|
||||
t.Errorf("发码参数错误: %+v", codeForm)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("切换响应 code 为字符串1也兼容", func(t *testing.T) {
|
||||
reset()
|
||||
changeResp = `{"code":"1","data":{"reAuthType":"11"}}`
|
||||
mfa := newMFA()
|
||||
if _, err := SendMFADynamicCode(context.Background(), mfa, "11"); err != nil {
|
||||
t.Fatalf("字符串 code=1 应视为切换成功,实际报错: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不在可用列表的方式被拒绝", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
if _, err := SendMFADynamicCode(context.Background(), mfa, "2"); err == nil {
|
||||
t.Fatal("不在列表中的方式应报错")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("列表内非动态码方式被拒绝", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
mfa.AuthMethods = append(mfa.AuthMethods, MFAAuthMethod{Type: "6", Name: "实人认证"})
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "6")
|
||||
if err == nil || !strings.Contains(err.Error(), "暂不支持发送验证码") {
|
||||
t.Fatalf("非动态码方式应明确报错,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("会话缺失返回过期错误", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
mfa.CookiesByHost = nil
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if !errors.Is(err, ErrMFASessionExpired) {
|
||||
t.Fatalf("会话缺失应返回 ErrMFASessionExpired,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("发码失败透传服务端文案", func(t *testing.T) {
|
||||
reset()
|
||||
codeResp = `{"res":"fail","returnMessage":"发送过于频繁,请稍后再试"}`
|
||||
mfa := newMFA()
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if err == nil || !strings.Contains(err.Error(), "发送过于频繁") {
|
||||
t.Fatalf("应透传服务端失败文案,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("倒计时未结束视为已发送", func(t *testing.T) {
|
||||
reset()
|
||||
codeResp = `{"res":"code_time_fail","returnMessage":"验证码已发送,请勿重复获取","codeTime":60}`
|
||||
mfa := newMFA()
|
||||
result, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if err != nil {
|
||||
t.Fatalf("code_time_fail 应视为发送成功: %v", err)
|
||||
}
|
||||
if result.CodeTime != 60 {
|
||||
t.Errorf("应回传倒计时秒数,实际: %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("会话失效错误码映射为过期", func(t *testing.T) {
|
||||
reset()
|
||||
codeResp = `{"res":"fail","errCode":"206302","returnMessage":"会话失效"}`
|
||||
mfa := newMFA()
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if !errors.Is(err, ErrMFASessionExpired) {
|
||||
t.Fatalf("errCode=206302 应映射为 ErrMFASessionExpired,实际: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 许昌学院(xcu)单独适配 ==========
|
||||
|
||||
// XcuSchoolId 许昌学院 tenant id
|
||||
const XcuSchoolId = "xcu"
|
||||
|
||||
// xcuCasLoginEntry 计算许昌学院 CAS 登录入口:
|
||||
// 校园网关的 /portal/login 会 302 到学校 CAS(authserver.xcu.edu.cn),并在 service 中
|
||||
// 携带网关回调(/iap/loginSuccess?sessionToken=...);走完 CAS 账密 + MFA 后回调该地址,
|
||||
// cookie 才能正确落在 xcu.campusphere.net 域(wec-* 任务接口所在域,activateSession 依赖)。
|
||||
// WecloudUrl 缺失时回退用学校 ampUrl(仅保证能走通 CAS 账密 + MFA)。
|
||||
func xcuCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/portal/login"
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// xcuCasLogin 许昌学院强制走 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但其 IAP 账密接口 /iap/doLogin 与通用
|
||||
// IAP 密码加密格式不兼容(实测 RSA hex/base64 等多种编码均返回 FAIL_UPNOTMATCH);
|
||||
// 实际登录链路为 IAP 入口 302 → 学校 CAS 账密 → MFA → 回调 /iap/loginSuccess 建立网关会话。
|
||||
// 因此不走通用 IAP 流程,强制用 CAS 流程并从校园网关入口进入(详见 xcuCasLoginEntry)。
|
||||
func xcuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
xcu := *school
|
||||
if entry := xcuCasLoginEntry(school); entry != "" {
|
||||
xcu.AmpUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &xcu, username, password)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestXcuCasLoginEntry 覆盖许昌学院 CAS 登录入口计算逻辑
|
||||
func TestXcuCasLoginEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
school *SchoolDetailInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "正常wecloud",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net"},
|
||||
want: "https://xcu.campusphere.net/portal/login",
|
||||
},
|
||||
{
|
||||
name: "wecloud带尾斜杠",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net/"},
|
||||
want: "https://xcu.campusphere.net/portal/login",
|
||||
},
|
||||
{
|
||||
name: "wecloud缺失回退ampUrl",
|
||||
school: &SchoolDetailInfo{
|
||||
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
|
||||
},
|
||||
want: "https://casp.xcu.edu.cn/newmobile/client",
|
||||
},
|
||||
{
|
||||
name: "均为空",
|
||||
school: &SchoolDetailInfo{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil学校",
|
||||
school: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := xcuCasLoginEntry(c.school); got != c.want {
|
||||
t.Errorf("xcuCasLoginEntry() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXcuCasLoginEntryOverridesAmpUrl 确认 xcuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
|
||||
func TestXcuCasLoginEntryOverridesAmpUrl(t *testing.T) {
|
||||
school := &SchoolDetailInfo{
|
||||
ID: XcuSchoolId,
|
||||
Name: "许昌学院",
|
||||
JoinType: "CLOUD",
|
||||
IdsUrl: "https://authserver.xcu.edu.cn/authserver",
|
||||
WecloudUrl: "https://xcu.campusphere.net",
|
||||
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
|
||||
}
|
||||
// 模拟 xcuCasLogin 内部的入口覆盖(不发起网络请求)
|
||||
clone := *school
|
||||
clone.AmpUrl = xcuCasLoginEntry(school)
|
||||
if got := getCasValidLoginUrl(&clone); got != "https://xcu.campusphere.net/portal/login" {
|
||||
t.Errorf("getCasValidLoginUrl() = %q, want 网关入口", got)
|
||||
}
|
||||
// 原对象不被修改
|
||||
if school.AmpUrl != "https://casp.xcu.edu.cn/newmobile/client" {
|
||||
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 西藏民族大学(xzmy)单独适配 ==========
|
||||
|
||||
// XzmySchoolId 西藏民族大学 tenant id
|
||||
const XzmySchoolId = "1019389288913029"
|
||||
|
||||
// 该校统一身份认证平台公钥(来自 cas.xzmu.edu.cn 前端 app.js 的 config 对象)
|
||||
const (
|
||||
xzmyRsaModulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"
|
||||
xzmyRsaExpHex = "010001"
|
||||
)
|
||||
|
||||
// xzmyCasEntry 该校 CAS 登录入口:wecloud 网关的 /iap 会 302 到学校 CAS
|
||||
// (cas.xzmu.edu.cn/lyuapServer/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// xzmy.campusphere.net 域(wec-* 任务接口所在域)。
|
||||
func xzmyCasEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
if school.IdsUrl != "" {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// xzmyEncryptPassword 该校(Leyan/lyuapServer 定制 CAS)密码加密:
|
||||
// 教科书式 RSA(无填充)——明文按字符铺入 chunkSize 字节块(小端序),
|
||||
// m^e mod n 后输出小写 hex。与通用 wisedu 的 PKCS#1 v1.5(crypto/rsa.EncryptPKCS1v15)
|
||||
// 完全不同:该校后端按小端 hex 解出原文,用 PKCS#1 填充会解成乱码 → PASSERROR。
|
||||
// 对应前端库 app.js 模块 24 的加密函数 W(barrett.powMod(m, e) → hex)。
|
||||
func xzmyEncryptPassword(password string) (string, error) {
|
||||
modBytes, err := hex.DecodeString(xzmyRsaModulusHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析公钥 modulus 失败: %w", err)
|
||||
}
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
chunkSize := (n.BitLen() + 7) / 8
|
||||
block := make([]byte, chunkSize)
|
||||
copy(block, []byte(password))
|
||||
// 小端序组装明文块
|
||||
m := new(big.Int)
|
||||
for i := chunkSize - 1; i >= 0; i-- {
|
||||
m.Lsh(m, 8)
|
||||
m.Or(m, big.NewInt(int64(block[i])))
|
||||
}
|
||||
expBytes, _ := hex.DecodeString(xzmyRsaExpHex)
|
||||
e := new(big.Int).SetBytes(expBytes)
|
||||
c := new(big.Int).Exp(m, e, n)
|
||||
return c.Text(16), nil
|
||||
}
|
||||
|
||||
// xzmyCasLogin 西藏民族大学强制走其定制 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 实际不可用,
|
||||
// IAP 账密提交报 FAIL_UPNOTMATCH(误导为"密码错误");
|
||||
// 真实链路为 /iap 302 → cas.xzmu.edu.cn/lyuapServer/login →(表单 POST
|
||||
// /lyuapServer/v1/tickets,密码=教科书RSA小端hex)→ 得 TGT/ST →
|
||||
// 回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
|
||||
func xzmyCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
entry := xzmyCasEntry(school)
|
||||
if entry == "" {
|
||||
return nil, nil, fmt.Errorf("西藏民族大学登录入口缺失")
|
||||
}
|
||||
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 从网关 /iap 进入,302 到 CAS,拿到带 service 的登录页地址
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(entry)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
|
||||
}
|
||||
loginURL := pageResp.RawResponse.Request.URL.String()
|
||||
if !strings.Contains(loginURL, "/lyuapServer/login") {
|
||||
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
casBase := fmt.Sprintf("%s://%s/lyuapServer", pageResp.RawResponse.Request.URL.Scheme, pageResp.RawResponse.Request.URL.Host)
|
||||
service := ""
|
||||
if u, perr := url.Parse(loginURL); perr == nil {
|
||||
service = u.Query().Get("service")
|
||||
}
|
||||
if service == "" {
|
||||
return nil, nil, fmt.Errorf("未获取到登录回调 service")
|
||||
}
|
||||
|
||||
encPwd, err := xzmyEncryptPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 2. 提交账密到 /lyuapServer/v1/tickets(表单编码)
|
||||
ticketResp, err := sc.R().SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"service": service,
|
||||
"loginType": "",
|
||||
"id": "",
|
||||
"code": "",
|
||||
}).
|
||||
Post(casBase + "/v1/tickets")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
respBody := readBody(ticketResp)
|
||||
tkt := parseXzmyTicket(string(respBody))
|
||||
if tkt.ticket == "" {
|
||||
// 有业务码则翻译为可读错误(NOAUTH/PASSERROR 等)
|
||||
if msg := xzmyLoginError(string(respBody)); msg != "" {
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("登录失败,请检查账号密码")
|
||||
}
|
||||
|
||||
// 3. 回调 service + ticket 建立网关会话
|
||||
sep := "&"
|
||||
if !strings.Contains(service, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
cbResp, err := sc.R().SetContext(ctx).Get(service + sep + "ticket=" + tkt.ticket)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("回调建立会话失败: %w", err)
|
||||
}
|
||||
_ = cbResp
|
||||
|
||||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||
|
||||
cookies := sc.jar.AllCookies()
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
return cookies, nil, nil
|
||||
}
|
||||
|
||||
// xzmyTicketResult v1/tickets 成功响应
|
||||
type xzmyTicketResult struct {
|
||||
tgt string
|
||||
ticket string
|
||||
}
|
||||
|
||||
// parseXzmyTicket 解析 {"tgt":"...","ticket":"..."}
|
||||
func parseXzmyTicket(body string) xzmyTicketResult {
|
||||
return xzmyTicketResult{
|
||||
tgt: jsonStrField(body, "tgt"),
|
||||
ticket: jsonStrField(body, "ticket"),
|
||||
}
|
||||
}
|
||||
|
||||
// xzmyLoginError 解析登录失败的 meta.message / data.code,返回可读错误(无则空串)
|
||||
func xzmyLoginError(body string) string {
|
||||
if code := jsonStrField(body, "code"); code != "" {
|
||||
switch code {
|
||||
case "PASSERROR", "PWDERROR", "PASSWORDERROR":
|
||||
return "用户名或密码不匹配,请检查后重试"
|
||||
case "NOAUTH", "USERNOTFOUND", "NOUSER":
|
||||
return "账号不存在或未注册统一身份认证"
|
||||
case "CODEFALSE":
|
||||
return "验证码错误"
|
||||
}
|
||||
return code
|
||||
}
|
||||
if msg := jsonStrField(body, "message"); msg != "" && msg != "ok" {
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonStrField 从(可能嵌套的)JSON 文本中提取指定 key 的字符串值(简化实现,够用即止)
|
||||
func jsonStrField(body, key string) string {
|
||||
needle := "\"" + key + "\":\""
|
||||
i := strings.Index(body, needle)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
i += len(needle)
|
||||
j := strings.Index(body[i:], "\"")
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return body[i : i+j]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestXzmyEncryptPassword 西藏民族大学密码加密:
|
||||
// 教科书式 RSA(无填充)+ 小端序零填充 + 小写 hex,与站点前端库一致。
|
||||
func TestXzmyEncryptPassword(t *testing.T) {
|
||||
got, err := xzmyEncryptPassword("zzynb666")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
// 金标准:由同一算法对 "zzynb666" 计算得到(该校 RSA 无填充,结果确定)
|
||||
const golden = "1605e4173d74759db8ed176c1ba5c2e7a1fed2e1e9fb081977e1debb659868c6287c563b8baa74274339618bae9806defd96b3c4e779ac09f5e26ce03d593057998e212e271dacf547e5c53fded7d912113f8cbb52b8fc1f7ecbfda4ba0fd30d2f812ba4f667c29a1d8082169b5c9261babdeb56f7a255d1a27968e99a66083d"
|
||||
if got != golden {
|
||||
t.Fatalf("密文与预期不符\n got=%s\nwant=%s", got, golden)
|
||||
}
|
||||
// 复原:密文按公钥长度应为 chunkSize(=modulus 字节数) 的 hex(无前导零时位数可少)
|
||||
modBytes, _ := hex.DecodeString(xzmyRsaModulusHex)
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
if len(got) == 0 || len(got) > 2*((n.BitLen()+7)/8) {
|
||||
t.Fatalf("密文长度异常: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestXzmyTicketParse 票据解析
|
||||
func TestXzmyTicketParse(t *testing.T) {
|
||||
body := `{"tgt":"TGT-1-abc","ticket":"ST-1-xyz"}`
|
||||
r := parseXzmyTicket(body)
|
||||
if r.tgt != "TGT-1-abc" || r.ticket != "ST-1-xyz" {
|
||||
t.Fatalf("解析错误: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXzmyLoginError 登录失败业务码翻译
|
||||
func TestXzmyLoginError(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"PASSERROR","data":"5,1","userId":"1"}}`: "用户名或密码不匹配,请检查后重试",
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"NOAUTH"}}`: "账号不存在或未注册统一身份认证",
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"CODEFALSE"}}`: "验证码错误",
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := xzmyLoginError(body); got != want {
|
||||
t.Fatalf("xzmyLoginError(%s) = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
// 成功响应应返回空串(不误判为错误)
|
||||
if got := xzmyLoginError(`{"tgt":"T","ticket":"ST"}`); got != "" {
|
||||
t.Fatalf("成功响应不应报错, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 南昌理工学院专用登录适配 ==========
|
||||
//
|
||||
// 该校(nut.campusphere.net)与标准金智 IAP / Apereo CAS 均不同,属于自研
|
||||
// 混合认证:
|
||||
// - /portal/login → 302 到 /iap/login?service=...(金智 IAP 入口)
|
||||
// - /iap/login 页面实际把表单提交到学校自研 CAS(my.nut.edu.cn/cas/login),
|
||||
// 表单字段为 username/password/authcode/execution/_eventId(CAS 风格)
|
||||
// - CAS 登录成功后 302 回跳 /iap/loginSuccess?sessionToken=xxx 建立 IAP 会话
|
||||
//
|
||||
// 该校密码加密特殊(见 security.js encryptedString):
|
||||
// - 密码先反转(split("").reverse().join(""))
|
||||
// - 明文块尾部补 0 到 chunkSize(2*模数最高非零 digit 索引)整数倍
|
||||
// - 每块按小端 16-bit digit 装配成大整数后直接做 m^e mod n(无 PKCS#1 填充)
|
||||
// - 输出各块 hex 密文,以空格分隔
|
||||
// - 公钥由 /cas/v2/getPubKey 动态返回(modulus/exponent,hex)
|
||||
//
|
||||
// 该校每次登录都需要图形验证码(/cas/v2/getKaptchaStatus 恒为 true),
|
||||
// 验证码图片地址 /cas/kaptcha?time=xxx。
|
||||
|
||||
// NutSchoolId 南昌理工学院 tenant id
|
||||
const NutSchoolId = "d6e8f2ca-10b2-4599-bdb8-4b33d419658d"
|
||||
|
||||
// nutCasIapLogin 南昌理工专用登录:CAS 认证 → 回跳 IAP 建立会话
|
||||
func nutCasIapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
base := strings.TrimSuffix(school.WecloudUrl, "/")
|
||||
if base == "" {
|
||||
base = strings.TrimSuffix(school.AmpUrl, "/")
|
||||
}
|
||||
slog.Info("南昌理工专用登录", "school", school.Name, "base", base)
|
||||
|
||||
// 1. /portal/login → 302 到 /iap/login?service=...
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
portalResp, err := sc.R().SetContext(ctx).Get(base + "/portal/login")
|
||||
if err != nil && portalResp == nil {
|
||||
return nil, fmt.Errorf("请求portal/login失败: %w", err)
|
||||
}
|
||||
portalLoc, err := portalResp.RawResponse.Location()
|
||||
if err != nil || portalLoc == nil {
|
||||
return nil, fmt.Errorf("获取portal/login跳转地址失败: %w", err)
|
||||
}
|
||||
slog.Info("南昌理工portal跳转", "url", portalLoc.String())
|
||||
|
||||
// 2. 跟随到 CAS 登录页(my.nut.edu.cn/cas/login?service=...)
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
loginPageResp, err := sc.R().SetContext(ctx).Get(portalLoc.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取登录页失败: %w", err)
|
||||
}
|
||||
pageBody := readBody(loginPageResp)
|
||||
pageURL := loginPageResp.RawResponse.Request.URL
|
||||
casBase := pageURL.Scheme + "://" + pageURL.Host
|
||||
slog.Info("南昌理工CAS登录页", "finalUrl", pageURL.String(), "casBase", casBase)
|
||||
|
||||
formAction, execution, err := parseNutCasForm(pageBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postUrl := casBase + formAction
|
||||
slog.Info("南昌理工CAS表单", "postUrl", postUrl, "executionLen", len(execution))
|
||||
|
||||
// 3-5. 提交登录:每次 attempt 重新获取登录页/公钥/验证码并加密提交
|
||||
// - execution 单次有效,重试必须刷新
|
||||
// - CAS 密钥对可能随登录页刷新而轮换,复用旧公钥加密会导致服务端
|
||||
// 解密失败,误报"用户名或密码错误"(2026-08-30 实测复现)
|
||||
// - 验证码依赖 OCR,存在误识别率;"用户名或密码错误"也可能是
|
||||
// 验证码错误被 CAS 统一提示,故一并纳入重试
|
||||
lastErr := ""
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if attempt > 1 {
|
||||
// 重试时重新获取登录页(execution 已失效)
|
||||
pageBody, execution, formAction, err = refreshNutCasForm(ctx, sc, portalLoc.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postUrl = casBase + formAction
|
||||
}
|
||||
|
||||
// 重新获取公钥并加密(每次 attempt 都刷新)
|
||||
pubKey, err := getNutPubKey(ctx, casBase, sc.Client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("南昌理工公钥", "modulusLen", len(pubKey.Modulus), "exponent", pubKey.Exponent)
|
||||
encPwd, err := nutRawRsaEncrypt(pubKey.Modulus, pubKey.Exponent, reverseString(password))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("南昌理工密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
imgData, err := getNutKaptcha(ctx, casBase, sc.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取南昌理工验证码失败: %w", err)
|
||||
}
|
||||
captchaCode := captchaOCR(imgData)
|
||||
slog.Info("南昌理工验证码OCR", "attempt", attempt, "code", captchaCode)
|
||||
if captchaCode == "" {
|
||||
lastErr = "验证码识别失败"
|
||||
continue
|
||||
}
|
||||
|
||||
submitData := map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"authcode": captchaCode,
|
||||
"execution": execution,
|
||||
"_eventId": "submit",
|
||||
"rememberMe": "on",
|
||||
}
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().SetContext(ctx).SetFormData(submitData).Post(postUrl)
|
||||
if err != nil && loginResp == nil {
|
||||
return nil, fmt.Errorf("提交南昌理工登录失败: %w", err)
|
||||
}
|
||||
|
||||
if loginResp.StatusCode() == 302 {
|
||||
loc, _ := loginResp.RawResponse.Location()
|
||||
slog.Info("南昌理工CAS登录成功", "redirect", loc.String())
|
||||
return followNutCasSuccess(ctx, loc, school, sc)
|
||||
}
|
||||
|
||||
// 200:重新渲染登录页,提取错误信息
|
||||
body := readBody(loginResp)
|
||||
msg := extractNutCasError(body)
|
||||
if msg == "" {
|
||||
msg = "登录失败"
|
||||
}
|
||||
slog.Info("南昌理工登录失败", "attempt", attempt, "msg", msg)
|
||||
if strings.Contains(msg, "验证码") || strings.Contains(msg, "用户名或密码") {
|
||||
lastErr = msg
|
||||
continue // OCR 误识别或凭据校验类错误,重试
|
||||
}
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
return nil, fmt.Errorf("%s", lastErr)
|
||||
}
|
||||
|
||||
// nutCasForm 登录页解析结果
|
||||
type nutCasForm struct {
|
||||
Action string
|
||||
Execution string
|
||||
}
|
||||
|
||||
func parseNutCasForm(page []byte) (action, execution string, err error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("解析南昌理工登录页失败: %w", err)
|
||||
}
|
||||
formSel := doc.Find("form").First()
|
||||
action, _ = formSel.Attr("action")
|
||||
if action == "" {
|
||||
return "", "", fmt.Errorf("南昌理工登录页缺少form action")
|
||||
}
|
||||
execution, _ = formSel.Find("input[name=execution]").Attr("value")
|
||||
if execution == "" {
|
||||
return "", "", fmt.Errorf("南昌理工登录页缺少execution")
|
||||
}
|
||||
return action, execution, nil
|
||||
}
|
||||
|
||||
// refreshNutCasForm 重新获取登录页与表单(验证码重试时使用)
|
||||
func refreshNutCasForm(ctx context.Context, sc *sessionClient, iapLoginUrl string) (page []byte, execution, action string, err error) {
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
resp, err := sc.R().SetContext(ctx).Get(iapLoginUrl)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("重新获取登录页失败: %w", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
action, execution, err = parseNutCasForm(body)
|
||||
return body, execution, action, err
|
||||
}
|
||||
|
||||
// nutPubKey 动态 RSA 公钥
|
||||
type nutPubKey struct {
|
||||
Modulus string
|
||||
Exponent string
|
||||
}
|
||||
|
||||
// getNutPubKey 获取动态 RSA 公钥
|
||||
func getNutPubKey(ctx context.Context, casBase string, client *resty.Client) (nutPubKey, error) {
|
||||
var result nutPubKey
|
||||
resp, err := client.R().SetContext(ctx).Get(casBase + "/cas/v2/getPubKey")
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("获取南昌理工公钥失败: %w", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return result, fmt.Errorf("解析南昌理工公钥失败: %w", err)
|
||||
}
|
||||
if result.Modulus == "" || result.Exponent == "" {
|
||||
return result, fmt.Errorf("南昌理工公钥为空")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getNutKaptcha 获取验证码图片
|
||||
func getNutKaptcha(ctx context.Context, casBase string, client *resty.Client) ([]byte, error) {
|
||||
ts := time.Now().UnixMilli()
|
||||
resp, err := client.R().SetContext(ctx).Get(fmt.Sprintf("%s/cas/kaptcha?time=%d", casBase, ts))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readBody(resp), nil
|
||||
}
|
||||
|
||||
// followNutCasSuccess 跟随 CAS 成功跳转,回到 IAP 建立会话
|
||||
func followNutCasSuccess(ctx context.Context, jumpUrl *url.URL, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) {
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("跟随南昌理工登录回跳失败: %w", err)
|
||||
}
|
||||
slog.Info("南昌理工IAP回跳", "status", verifyResp.StatusCode(), "finalUrl", verifyResp.RawResponse.Request.URL.String())
|
||||
|
||||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||
return sc.jar.AllCookies(), nil
|
||||
}
|
||||
|
||||
// extractNutCasError 提取登录失败页的错误信息(#msg 或 #errormsg)
|
||||
func extractNutCasError(page []byte) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, sel := range []string{"#msg", "#errormsg", ".error"} {
|
||||
msg := strings.TrimSpace(doc.Find(sel).First().Text())
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// nutRawRsaEncrypt 南昌理工 raw RSA:明文块直接模幂(无 PKCS#1 填充),hex 空格分隔
|
||||
func nutRawRsaEncrypt(modulusHex, exponentHex, msg string) (string, error) {
|
||||
n := new(big.Int)
|
||||
if _, ok := n.SetString(modulusHex, 16); !ok {
|
||||
return "", fmt.Errorf("modulus解析失败")
|
||||
}
|
||||
e := new(big.Int)
|
||||
if _, ok := e.SetString(exponentHex, 16); !ok {
|
||||
return "", fmt.Errorf("exponent解析失败")
|
||||
}
|
||||
|
||||
// chunkSize = 2 * biHighIndex(m)
|
||||
// biHighIndex: modulus 按 16-bit 小端 digit 存储时最高非零 digit 索引
|
||||
modBytes, err := hex.DecodeString(modulusHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("modulus hex解码失败: %w", err)
|
||||
}
|
||||
digitCount := (len(modBytes) + 1) / 2
|
||||
biHighIndex := -1
|
||||
for i := digitCount - 1; i >= 0; i-- {
|
||||
lo := modBytes[len(modBytes)-1-i*2]
|
||||
var hi byte
|
||||
if idx := len(modBytes) - 2 - i*2; idx >= 0 {
|
||||
hi = modBytes[idx]
|
||||
}
|
||||
if uint16(hi)<<8|uint16(lo) != 0 {
|
||||
biHighIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if biHighIndex <= 0 {
|
||||
return "", fmt.Errorf("modulus异常(biHighIndex=%d)", biHighIndex)
|
||||
}
|
||||
chunkSize := 2 * biHighIndex
|
||||
|
||||
// 明文补 0 到 chunkSize 整数倍
|
||||
a := []byte(msg)
|
||||
for len(a)%chunkSize != 0 {
|
||||
a = append(a, 0)
|
||||
}
|
||||
|
||||
// 分块加密:块内每 2 字节小端装配为一个 16-bit digit
|
||||
var parts []string
|
||||
for i := 0; i < len(a); i += chunkSize {
|
||||
block := new(big.Int)
|
||||
for j := 0; j < chunkSize/2; j++ {
|
||||
d := uint16(a[i+2*j]) | uint16(a[i+2*j+1])<<8
|
||||
block.Add(block, new(big.Int).Lsh(big.NewInt(int64(d)), uint(16*j)))
|
||||
}
|
||||
c := new(big.Int).Exp(block, e, n)
|
||||
parts = append(parts, c.Text(16))
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
func reverseString(s string) string {
|
||||
r := []rune(s)
|
||||
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
|
||||
r[i], r[j] = r[j], r[i]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
|
||||
MobilePhone string `json:"mobilePhone"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Data == nil {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// qrUuidPattern 匹配二维码内容中的 uuid 参数(如 ...?uuid=1583a6...&isNeedExtra=0)
|
||||
var qrUuidPattern = regexp.MustCompile(`uuid=([0-9a-zA-Z_-]+)`)
|
||||
|
||||
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串,如 1583a66272854585833eb13b56fc1937)
|
||||
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
|
||||
// bareTokenPattern 匹配"整串即 uuid 的裸值",用于存库后回读的场景。
|
||||
// 链接里的 uuid 允许 [0-9a-zA-Z_-]+(见 qrUuidPattern),并非都是 32 位 hex,
|
||||
// 因此仅靠 bareUuidPattern 无法反解非 hex 的 uuid,会导致签到失败。
|
||||
// 长度限制 8~64 且不含空白/URL 符号,避免把普通文本误判为 uuid。
|
||||
var bareTokenPattern = regexp.MustCompile(`^[0-9a-zA-Z_-]{8,64}$`)
|
||||
|
||||
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
|
||||
// 支持三种形式:完整链接(取 uuid= 参数值)、32 位 hex 裸 uuid、其它形式的裸 uuid token。
|
||||
//
|
||||
// 二维码图片的识别在前端完成(jsQR),后端只接收解析后的 uuid 文本,故此处不涉及图片解码。
|
||||
func ExtractQrUuid(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if m := qrUuidPattern.FindStringSubmatch(text); len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
if bareUuidPattern.MatchString(text) {
|
||||
return strings.ToLower(text)
|
||||
}
|
||||
if bareTokenPattern.MatchString(text) {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testQrUuid = "1583a66272854585833eb13b56fc1937"
|
||||
|
||||
func TestExtractQrUuid(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0&schoolId=1018615911902606", "1583a66272854585833eb13b56fc1937"},
|
||||
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"},
|
||||
{" xxxxx?uuid=abc123XYZ&y=1 ", "abc123XYZ"},
|
||||
{"no uuid here", ""},
|
||||
{"", ""},
|
||||
// 裸 uuid 回读(formData 只存 uuid 后的解析路径)
|
||||
{"abc123XYZ", "abc123XYZ"}, // 非 32-hex 的裸 uuid
|
||||
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"}, // 32-hex
|
||||
{"uuid_with-dash", "uuid_with-dash"}, // 含 _ 和 -
|
||||
{"ab", ""}, // 太短,不认为是 uuid
|
||||
{"has space x", ""}, // 含空白,不认为是 uuid
|
||||
{"https://x.edu.cn/a/b", ""}, // URL 含符号,不走裸 token 分支
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ExtractQrUuid(c.in); got != c.want {
|
||||
t.Errorf("ExtractQrUuid(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -16,6 +17,44 @@ import (
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 登录态失效的统一识别 ==========
|
||||
|
||||
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
|
||||
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
|
||||
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
|
||||
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
|
||||
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
|
||||
|
||||
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
|
||||
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
|
||||
func looksLikeHTML(body []byte) bool {
|
||||
head := body
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(head))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.Contains(lower, "<!doctype html") ||
|
||||
strings.Contains(lower, "<html")
|
||||
}
|
||||
|
||||
// unmarshalBody 解析接口 JSON 响应体。
|
||||
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
|
||||
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
|
||||
// "invalid character '<' looking for beginning of value"。
|
||||
func unmarshalBody(body []byte, v interface{}) error {
|
||||
if looksLikeHTML(body) {
|
||||
return ErrCookieExpired
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// ========== HTTP 客户端 ==========
|
||||
|
||||
var httpClient *resty.Client
|
||||
@@ -149,11 +188,74 @@ var iapSchoolIds = map[string]bool{
|
||||
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
|
||||
}
|
||||
|
||||
// hasUsableLoginUrl 判断学校是否具备可用的登录地址(IAP/CAS 都需要)
|
||||
func hasUsableLoginUrl(school *SchoolDetailInfo) bool {
|
||||
return school != nil && (school.IdsUrl != "" || school.WecloudUrl != "" ||
|
||||
school.AmpUrl != "" || school.AmpUrl2 != "" || school.AmpUrl3 != "")
|
||||
}
|
||||
|
||||
// CpdailyLogin 执行 cpdaily 登录
|
||||
// 返回登录成功后的所有 cookies
|
||||
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
// 返回登录成功后的所有 cookies;若触发多因子认证(MFA),返回 (nil, mfaCtx, ErrMFARequired),
|
||||
// 调用方应暂存 mfaCtx 并引导用户完成验证码校验(CompleteMFALogin)。
|
||||
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
// 南昌理工学院:自研 CAS→IAP 混合认证,与通用 IAP/CAS 流程均不兼容,单独适配
|
||||
if school.ID == NutSchoolId {
|
||||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
if school.ID == HfnuSchoolId {
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 许昌学院:平台配置 joinType=CLOUD(IsIap=true),但 IAP 账密登录实际不兼容;
|
||||
// 学校真实链路为 CAS 账密 + MFA + /iap/loginSuccess 回调,单独强制走 CAS(详见 login_xcu.go)
|
||||
if school.ID == XcuSchoolId {
|
||||
return xcuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 辽宁工程技术大学:平台配置 joinType=CLOUD(IsIap=true),但网关 /iap/doLogin 不存在(405),
|
||||
// IAP 提交报 FAIL_UPNOTMATCH 误导为"密码错误";实际链路为 /iap 302 → 学校 CAS(authserver.lntu.edu.cn)
|
||||
// → 回调 /iap/loginSuccess 建立网关会话,同 xcu 模式单独强制走 CAS(详见 login_lntu.go)
|
||||
if school.ID == LntuSchoolId {
|
||||
return lntuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 西藏民族大学:平台配置 joinType=CLOUD(IsIap=true),但网关 IAP 账密不可用,
|
||||
// IAP 提交报 FAIL_UPNOTMATCH("密码错误");实际为定制 CAS(cas.xzmu.edu.cn/lyuapServer,
|
||||
// 密码=教科书RSA小端hex,POST /v1/tickets),单独适配(详见 login_xzmy.go)
|
||||
if school.ID == XzmySchoolId {
|
||||
return xzmyCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
|
||||
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
|
||||
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
|
||||
if school.ID == LutSchoolId {
|
||||
return lutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
// 合肥工业大学:平台配置 joinType=CLOUD(IsIap=true),idsUrl=hfut.campusphere.net/iap,
|
||||
// 但网关 IAP 账密接口不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
|
||||
// (标准 Apereo CAS),密码 AES-ECB 加密、key 由 /cas/checkInitParams 下发的
|
||||
// LOGIN_FLAVORING cookie 提供,通用 casLogin 拿不到该 key 故报"账号或密码错误";
|
||||
// 单独适配(详见 login_hfut.go)
|
||||
if school.ID == HfutSchoolId {
|
||||
return hfutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
return iapLogin(ctx, school, username, password)
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
return nil, nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
|
||||
}
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("CAS学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
return nil, nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
|
||||
}
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user