Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aab416125 | |||
| 6ad0db6e3a | |||
| dab1004db0 | |||
| 48023f43af | |||
| dcf5b312de | |||
| f28e8e2aa7 | |||
| f32a16f788 | |||
| 14884a0037 | |||
| f3ce1428e5 | |||
| 81ee9b5036 | |||
| d23189f72a | |||
| e6820b3ecb | |||
| c99e878328 | |||
| 048d0d6820 | |||
| 00d1b8ee5c | |||
| d43d3cfb46 | |||
| 9c97fe770d | |||
| 566f460e05 | |||
| 040b1aef18 | |||
| a5bb9843c0 | |||
| 19d8c392c3 | |||
| 93df73a179 | |||
| e7e8b86b42 | |||
| 30baaa6507 | |||
| 64ccac1042 | |||
| e5b57203fb | |||
| a51501909a | |||
| 672214c06e | |||
| 4cc36658c0 | |||
| c75e55c6e7 | |||
| 315942cfce | |||
| fd214d06eb | |||
| 48c3948c56 | |||
| 0d3280fe7b | |||
| 0d639ea485 | |||
| 3b4a8df8fd | |||
| 5a33afa213 | |||
| eaafc50739 | |||
| 44f93a0ae9 | |||
| 8926c0b109 | |||
| 320f21442c | |||
| 770b1754b4 | |||
| 651739af1b | |||
| 608dee697f | |||
| fab756a1ef | |||
| b503f44a2d | |||
| adb8bb7560 | |||
| 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 |
+112
-77
@@ -1,6 +1,6 @@
|
||||
when:
|
||||
- event: tag
|
||||
tag: v*
|
||||
ref: refs/tags/v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
@@ -10,7 +10,6 @@ clone:
|
||||
|
||||
steps:
|
||||
build-image:
|
||||
name: 构建 Docker 镜像
|
||||
image: docker
|
||||
commands: |
|
||||
docker build -t cpdaily-xiaozhushou:latest .
|
||||
@@ -19,7 +18,7 @@ steps:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
deploy:
|
||||
name: 蓝绿部署
|
||||
depends_on: [build-image]
|
||||
image: docker
|
||||
commands: |
|
||||
set -e
|
||||
@@ -29,112 +28,148 @@ steps:
|
||||
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
|
||||
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
|
||||
|
||||
# ============================================================
|
||||
# 第一步:读取当前反代端口,确定蓝绿状态
|
||||
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
|
||||
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
|
||||
# ============================================================
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前反代端口: $CURRENT_PORT ==="
|
||||
# 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着":
|
||||
# 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚)
|
||||
SLOT_BLUE=8884
|
||||
SLOT_GREEN=8885
|
||||
|
||||
if [ "$CURRENT_PORT" = "8884" ]; then
|
||||
PORT_ACTIVE=8884
|
||||
PORT_STANDBY=8885
|
||||
elif [ "$CURRENT_PORT" = "8885" ]; then
|
||||
PORT_ACTIVE=8885
|
||||
PORT_STANDBY=8884
|
||||
# ============================================================
|
||||
# 第一步:确定本次要更新的槽位
|
||||
# 本机 OpenResty 暂未配置 cpdaily 站点,没有可供切换的反代入口,
|
||||
# 因此只做「容器级蓝绿」:两个槽保持一旧一新,每次只重建其中一个,
|
||||
# 另一个继续运行上一版本,便于手工回滚。槽位记录在宿主机文件里交替。
|
||||
# ============================================================
|
||||
SLOT_FILE=/opt/cpdaily-xiaozhushou/current-slot
|
||||
if [ -f "$SLOT_FILE" ]; then
|
||||
CURRENT_SLOT=$(cat "$SLOT_FILE")
|
||||
else
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
|
||||
exit 1
|
||||
# 首次:当前 8884 为较新版本,本次更新另一个槽
|
||||
CURRENT_SLOT=$SLOT_GREEN
|
||||
fi
|
||||
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||
|
||||
# 清理之前可能残留的临时容器
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
if [ "$CURRENT_SLOT" = "$SLOT_BLUE" ]; then
|
||||
PORT_ACTIVE=$SLOT_BLUE
|
||||
PORT_STANDBY=$SLOT_GREEN
|
||||
else
|
||||
PORT_ACTIVE=$SLOT_GREEN
|
||||
PORT_STANDBY=$SLOT_BLUE
|
||||
fi
|
||||
echo "=== 当前版本槽: $PORT_ACTIVE | 本次更新槽: $PORT_STANDBY ==="
|
||||
|
||||
# ============================================================
|
||||
# 第二步:在备用端口启动新容器
|
||||
# 第一步附:一次性「收养」旧命名容器
|
||||
# 迁移前的容器名为 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 \
|
||||
--network 1panel-network \
|
||||
-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 反代到备用端口
|
||||
# ============================================================
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
# 记录本次更新成功的槽,下次部署自动切到另一个槽
|
||||
echo "$PORT_STANDBY" > "$SLOT_FILE"
|
||||
echo " 已记录当前版本槽: $PORT_STANDBY"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:停止旧容器
|
||||
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
|
||||
# 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。
|
||||
# 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。
|
||||
# ============================================================
|
||||
echo "=== 清理旧容器 ==="
|
||||
docker stop cpdaily-xiaozhushou 2>/dev/null || true
|
||||
docker rm cpdaily-xiaozhushou 2>/dev/null || true
|
||||
echo "=== 当前容器 ==="
|
||||
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
|
||||
|
||||
# ============================================================
|
||||
# 第六步:在活跃端口启动正式容器
|
||||
# ============================================================
|
||||
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
|
||||
docker run -d --name cpdaily-xiaozhushou \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_ACTIVE:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
# 清理历史镜像:只保留最近的 5 个版本。
|
||||
# latest 与当前正在部署的 IMAGE_TAG 永不删除 —— deploy-162 步骤与此步骤并行,
|
||||
# 如果误删当前 tag,它会 docker save 失败。因此先排除这两个,再按新旧顺序裁掉尾部。
|
||||
echo "=== 清理历史镜像(保留最近 5 个版本)==="
|
||||
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
|
||||
| grep -v '^latest$' | grep -v "^$IMAGE_TAG\$" \
|
||||
| tail -n +6 | while read tag; do
|
||||
[ -n "$tag" ] && docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ============================================================
|
||||
# 第七步:切回 1Panel 反代到活跃端口
|
||||
# ============================================================
|
||||
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# ============================================================
|
||||
# 第八步:清理
|
||||
# ============================================================
|
||||
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
|
||||
done
|
||||
echo "=== 部署完成 ✓ ==="
|
||||
echo "=== 部署完成 ✓ 本次更新槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/cpdaily-xiaozhushou:/opt/cpdaily-xiaozhushou
|
||||
|
||||
# ============================================================
|
||||
# 新增:同步部署到 162(跨机蓝绿)
|
||||
# 本机部署步骤保持不变,这里只做「镜像推送 + 远程触发脚本」
|
||||
# ============================================================
|
||||
deploy-162:
|
||||
# 只依赖构建产物,不受上面 deploy 步骤成败影响
|
||||
depends_on: [build-image]
|
||||
when:
|
||||
- status: [success, failure]
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 宿主机 ssh 密钥(config 内含 do-162-14-81-139 别名与 IdentityFile)
|
||||
- /root/.ssh:/root/.ssh:ro
|
||||
commands: |
|
||||
set -e
|
||||
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
|
||||
# alpine 官方源在本机不可达,改用国内镜像
|
||||
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
|
||||
apk add --no-cache openssh-client
|
||||
|
||||
echo "=== 推送镜像 $IMAGE_TAG 到 162 ==="
|
||||
docker save cpdaily-xiaozhushou:$IMAGE_TAG | gzip -1 | \
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null do-162-14-81-139 \
|
||||
"gunzip | docker load"
|
||||
|
||||
echo "=== 触发 162 蓝绿部署 ==="
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null do-162-14-81-139 \
|
||||
"/opt/cpdaily-xiaozhushou/deploy-bluegreen.sh $IMAGE_TAG"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
)
|
||||
|
||||
@@ -35,6 +37,8 @@ func main() {
|
||||
|
||||
// 连接数据库
|
||||
database.Init()
|
||||
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
|
||||
handler.BackfillInviteCodes()
|
||||
|
||||
// 连接 Redis(验证码存储/限流)
|
||||
service.InitRedis()
|
||||
@@ -55,6 +59,25 @@ func main() {
|
||||
// 中间件
|
||||
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")
|
||||
|
||||
@@ -63,12 +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("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
@@ -79,6 +107,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)
|
||||
@@ -87,6 +116,20 @@ 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)
|
||||
@@ -132,6 +175,7 @@ func main() {
|
||||
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /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)
|
||||
@@ -146,12 +190,14 @@ func main() {
|
||||
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)
|
||||
@@ -165,6 +211,8 @@ func main() {
|
||||
ticket.Get("/:id", handler.GetTicketDetail)
|
||||
ticket.Post("/:id/message", handler.AddTicketMessage)
|
||||
ticket.Put("/:id/close", handler.CloseTicket)
|
||||
// 打回:仅 resolved → processing,放在 /:id/close 同层,不与 /:id 冲突
|
||||
ticket.Put("/:id/reopen", handler.UserReopenTicket)
|
||||
ticket.Delete("/:id", handler.DeleteTicket)
|
||||
ticket.Get("/notifications/list", handler.ListTicketNotifications)
|
||||
ticket.Get("/notifications/unread-count", handler.GetTicketUnreadCount)
|
||||
|
||||
@@ -31,7 +31,7 @@ length = 4
|
||||
appVersion = "9.8.8"
|
||||
systemVersion = "iOS 17"
|
||||
calVersion = "first_v4"
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx", "lntu"]
|
||||
captchaBaseUrl = "http://127.0.0.1:8890"
|
||||
captchaApiKey = "your_captcha_api_key"
|
||||
|
||||
|
||||
+3
-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
|
||||
)
|
||||
@@ -21,6 +21,7 @@ require (
|
||||
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
|
||||
@@ -28,13 +29,10 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/redis/go-redis/v9 v9.7.0 // 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/image v0.46.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
|
||||
+9
-6
@@ -8,6 +8,10 @@ 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=
|
||||
@@ -36,12 +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=
|
||||
@@ -95,8 +99,6 @@ 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=
|
||||
@@ -117,10 +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=
|
||||
@@ -131,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=
|
||||
|
||||
@@ -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,6 +5,7 @@ import (
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,7 +23,7 @@ type SysUserListItem struct {
|
||||
RealName string `json:"realName"`
|
||||
MobileCode string `json:"mobileCode"`
|
||||
MobileType string `json:"mobileType"`
|
||||
SignCount int `json:"signCount"`
|
||||
SignCount float64 `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
|
||||
@@ -103,7 +104,8 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
taskCountMap[tc.UserID] = tc.Count
|
||||
}
|
||||
|
||||
// 累计成功执行次数(cp_task_logs is_success=1)
|
||||
// 累计成功执行次数(cp_task_logs 签到/查寝 + cp_leave_logs 请假/销假,含用户已删除的软删除记录)
|
||||
// 说明:用户在客户端"删除"日志只是前台不可见(软删除),后台统计必须计入,否则执行次数会凭空减少
|
||||
executedMap := make(map[uint]int64)
|
||||
// 今天执行状态(success/fail/none)
|
||||
todayStatusMap := make(map[uint]string)
|
||||
@@ -118,23 +120,46 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
Count int64
|
||||
MaxStatus int
|
||||
}
|
||||
// 签到/查寝成功次数(Unscoped 包含软删除)
|
||||
var logStats []LogStat
|
||||
database.DB.Model(&model.CpTaskLog{}).
|
||||
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
|
||||
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 起的日志
|
||||
// 今天 00:00 起的状态(含软删除):签到/查寝 + 请假/销假两类日志统一判定
|
||||
// 任一成功 → success;否则任有失败 → fail;都无 → none
|
||||
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
|
||||
var todayLogs []model.CpTaskLog
|
||||
database.DB.Select("user_id, is_success").
|
||||
var todayTaskLogs []model.CpTaskLog
|
||||
database.DB.Unscoped().Select("user_id, is_success").
|
||||
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
|
||||
Find(&todayLogs)
|
||||
for _, tl := range todayLogs {
|
||||
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" {
|
||||
@@ -215,10 +240,16 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 当天统计:执行总次数 / 成功执行总次数 / 新增用户数 / 登录用户数
|
||||
// 执行类统计 = 签到/查寝 + 请假/销假(均含用户已删除的软删除记录)
|
||||
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
|
||||
var todayExecuteCount, todaySuccessCount int64
|
||||
database.DB.Model(&model.CpTaskLog{}).Where("created_at >= ?", todayStart).Count(&todayExecuteCount)
|
||||
database.DB.Model(&model.CpTaskLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todaySuccessCount)
|
||||
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)
|
||||
@@ -229,9 +260,9 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"todayStats": fiber.Map{
|
||||
"executeCount": todayExecuteCount,
|
||||
"successCount": todaySuccessCount,
|
||||
"newUserCount": todayNewUsers,
|
||||
"executeCount": todayExecuteCount,
|
||||
"successCount": todaySuccessCount,
|
||||
"newUserCount": todayNewUsers,
|
||||
"loginUserCount": todayLoginUsers,
|
||||
},
|
||||
})
|
||||
@@ -274,26 +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,
|
||||
"inviterId": user.InviterID,
|
||||
"inviterName": inviterName,
|
||||
"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,
|
||||
"lastLoginIp": user.LastLoginIP,
|
||||
"taskCount": taskCount,
|
||||
"mobileCode": user.MobileCode,
|
||||
"mobileType": user.MobileType,
|
||||
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"lastLoginAt": lastLogin,
|
||||
"lastLoginIp": user.LastLoginIP,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,9 +385,35 @@ type AdminTaskLogItem struct {
|
||||
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,默认当天)
|
||||
@@ -387,32 +444,90 @@ func AdminListTaskLogs(c *fiber.Ctx) error {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := database.DB.Table("cp_task_logs").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = cp_task_logs.user_id").
|
||||
Where("cp_task_logs.created_at >= ? AND cp_task_logs.created_at < ?", dayStart, dayEnd)
|
||||
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" {
|
||||
query = query.Where("cp_task_logs.is_success = 1")
|
||||
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd)
|
||||
} else if status == "fail" {
|
||||
query = query.Where("cp_task_logs.is_success = 0")
|
||||
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 0", dayStart, dayEnd, dayStart, dayEnd)
|
||||
}
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("sys_user.username LIKE ? OR sys_user.real_name LIKE ? OR cp_task_logs.title LIKE ? OR cp_task_logs.real_title LIKE ?",
|
||||
like, like, like, like)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
// 关键字过滤(用户名/姓名/标题)在内存中完成(跨表 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)
|
||||
})
|
||||
|
||||
var items []AdminTaskLogItem
|
||||
query.Select("cp_task_logs.id, cp_task_logs.user_id, sys_user.username, sys_user.real_name, sys_user.school_name, cp_task_logs.task_type, cp_task_logs.title, cp_task_logs.real_title, cp_task_logs.is_success, cp_task_logs.error_message, cp_task_logs.created_at").
|
||||
Order("cp_task_logs.created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Scan(&items)
|
||||
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": items,
|
||||
"items": filtered[start:end],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
|
||||
+275
-180
@@ -1,13 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
@@ -26,11 +26,8 @@ import (
|
||||
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
// 限流 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)))
|
||||
@@ -67,7 +64,6 @@ type LoginRequest struct {
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
@@ -95,8 +91,8 @@ func Login(c *fiber.Ctx) error {
|
||||
// 执行 cpdaily 登录
|
||||
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, req.InviteCode, mfaCtx)
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
|
||||
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("cpdaily登录失败", "error", err)
|
||||
@@ -110,7 +106,7 @@ func Login(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
|
||||
}
|
||||
|
||||
// MFAVerifyRequest 提交多因子认证验证码
|
||||
@@ -157,12 +153,59 @@ func MFAVerify(c *fiber.Ctx) error {
|
||||
deleteMFASession(c.Context(), req.MfaToken)
|
||||
|
||||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
|
||||
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, referralCode string) error {
|
||||
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 {
|
||||
@@ -188,7 +231,7 @@ func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
userName := buildUsername(school, username)
|
||||
|
||||
// 查找或创建用户
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
@@ -294,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v2"
|
||||
const GuideCompleteVersion = "v4"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
@@ -306,17 +349,47 @@ func CompleteGuide(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
// 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)
|
||||
}
|
||||
@@ -370,18 +443,6 @@ 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{
|
||||
@@ -419,10 +480,12 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
|
||||
// 查找或创建用户
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
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 {
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
}
|
||||
|
||||
@@ -430,27 +493,27 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
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, 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),
|
||||
@@ -458,125 +521,24 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
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
|
||||
}
|
||||
|
||||
// 记录邀请关系(后台用户管理可展示"被谁邀请")
|
||||
tx.Model(&user).Update("inviter_id", inviter.ID)
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
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, 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)
|
||||
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,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新邀请码(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,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||
@@ -586,15 +548,121 @@ 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{
|
||||
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, "请输入邀请码")
|
||||
}
|
||||
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
|
||||
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
|
||||
// 用统一文案,不泄露「码存在但格式对」这类信息。
|
||||
if !isValidInviteCodeShape(code) {
|
||||
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.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 次签到)
|
||||
@@ -627,12 +695,16 @@ 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)会话暂存 ==========
|
||||
@@ -644,15 +716,15 @@ const (
|
||||
|
||||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||||
type mfaSession struct {
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
}
|
||||
|
||||
// startMFALogin 暂存 MFA 会话并返回 mfaToken,前端引导用户提交验证码
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
|
||||
// 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, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||||
}
|
||||
@@ -670,23 +742,25 @@ func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(mfaSession{
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
InviteCode: inviteCode,
|
||||
MFA: mfaCtx,
|
||||
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, "reAuthType", mfaCtx.ReAuthType)
|
||||
// HTTP 200 + 专用业务码 4281:前端按 code 弹出验证码输入弹窗(而非登录失败弹窗)
|
||||
return utils.RespondWithCode(c, utils.CodeMFARequired, "该校登录需多因子认证,验证码已发送至今日校园,请查收后输入", fiber.Map{
|
||||
"needMFA": true,
|
||||
"mfaToken": mfaToken,
|
||||
"expiresIn": int(mfaSessionTTL.Seconds()),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -719,3 +793,24 @@ func deleteMFASession(ctx context.Context, mfaToken string) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
@@ -105,7 +151,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
|
||||
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
|
||||
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
|
||||
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
|
||||
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// 检查 MOD_ cookie 确保登录成功
|
||||
@@ -128,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)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,25 @@ func getVipCardPrice(key string, def float64) float64 {
|
||||
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)
|
||||
@@ -502,5 +521,7 @@ func GetUserVipInfo(c *fiber.Ctx) error {
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 8
|
||||
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)
|
||||
}
|
||||
|
||||
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
|
||||
//
|
||||
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
|
||||
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
|
||||
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
|
||||
func resetInviteCode(user *model.SysUser) (string, error) {
|
||||
if user == nil {
|
||||
return "", errors.New("用户不存在")
|
||||
}
|
||||
oldCode := strings.TrimSpace(user.InviteCode)
|
||||
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
|
||||
if oldCode != "" {
|
||||
// 乐观锁:旧码未被他人改动才允许替换
|
||||
q = q.Where("invite_code = ?", oldCode)
|
||||
}
|
||||
res := q.Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.InviteCode = code
|
||||
return code, nil
|
||||
}
|
||||
|
||||
var errInviteCodeChanged = errors.New("invite code changed concurrently")
|
||||
|
||||
// ResetInviteCode 用户主动重置自己的邀请码。
|
||||
//
|
||||
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
|
||||
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
|
||||
func ResetInviteCode(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, "用户不存在")
|
||||
}
|
||||
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
|
||||
if strings.TrimSpace(user.InviteCode) == "" {
|
||||
if err := ensureInviteCode(&user); err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
oldCode := user.InviteCode
|
||||
code, err := resetInviteCode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInviteCodeChanged) {
|
||||
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
|
||||
}
|
||||
slog.Error("重置邀请码失败", "userId", userID, "error", err)
|
||||
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
|
||||
}
|
||||
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
|
||||
return utils.Success(c, fiber.Map{"inviteCode": code})
|
||||
}
|
||||
|
||||
// inviteCodeLegacyLength 旧版邀请码长度。
|
||||
//
|
||||
// 2026-10 之前生成为 6 位,生产库中仍有大量存量用户沿用旧码
|
||||
// (实测 551 个 6 位 / 1 个 8 位)。校验必须同时接受两种长度,
|
||||
// 否则存量用户的码会被判为无效,导致他们无法被邀请。
|
||||
const inviteCodeLegacyLength = 6
|
||||
|
||||
// isValidInviteCodeShape 校验邀请码的形态(长度 + 字符集),不查库。
|
||||
//
|
||||
// 长度接受 6(存量)与 8(当前)两种,字符集为 inviteCodeChars
|
||||
// (已去掉易混淆的 0/O/1/L)。不合规的输入无需查库即可拒绝,
|
||||
// 也避免把用户的任意长字符串当码去查。
|
||||
func isValidInviteCodeShape(code string) bool {
|
||||
if len(code) != inviteCodeLength && len(code) != inviteCodeLegacyLength {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(code); i++ {
|
||||
if !strings.ContainsRune(inviteCodeChars, rune(code[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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,204 @@
|
||||
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("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// 重置邀请码:新旧码必须不同,且新码格式合规。
|
||||
func TestResetInviteCodeProducesNewCode(t *testing.T) {
|
||||
old, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{old: true}
|
||||
for i := 0; i < 100; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("生成重复码 %q", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
|
||||
func TestInviteCodeLengthIsEight(t *testing.T) {
|
||||
if inviteCodeLength != 8 {
|
||||
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
|
||||
}
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
|
||||
if dup := n - len(seen); dup > 0 {
|
||||
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
|
||||
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
|
||||
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
|
||||
t.Fatal("两个哨兵错误不应互相匹配")
|
||||
}
|
||||
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
|
||||
t.Fatal("errors.Is 自反性失败")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
|
||||
func TestIsValidInviteCodeShape(t *testing.T) {
|
||||
valid, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"正常 8 位", valid, true},
|
||||
{"空串", "", false},
|
||||
{"过短", "ABC", false},
|
||||
{"过长", "ABCDEFGHJ", false},
|
||||
{"含易混淆字符 0", "ABCD0EFG", false},
|
||||
{"含易混淆字符 O", "ABCDOEFG", false},
|
||||
{"含易混淆字符 1", "ABCD1EFG", false},
|
||||
{"含易混淆字符 I", "ABCDIEFG", false},
|
||||
{"含易混淆字符 L", "ABCDLEFG", false},
|
||||
{"含小写", "abcdefgh", false},
|
||||
{"含符号", "ABCD-EFG", false},
|
||||
{"含中文", "邀请码邀请码邀请", false},
|
||||
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
|
||||
{"存量 6 位码", "ABCDEF", true},
|
||||
{"存量 6 位含易混淆字符", "ABCD0F", false},
|
||||
{"5 位", "ABCDE", false},
|
||||
{"7 位", "ABCDEFG", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isValidInviteCodeShape(c.code); got != c.want {
|
||||
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"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
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户销假串行执行,避免并发请求重复提交学校端。
|
||||
// 销假是同步阻塞流程,TTL 取 5 分钟已足够覆盖单次执行。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:leave:%d", userID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("销假正在执行中,拒绝重复提交", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "操作正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
// 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,840 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"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
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户请假串行执行,避免并发请求重复提交学校端。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:leave-apply:%d", userID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("请假正在执行中,拒绝重复提交", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "操作正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
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, cpd.AndroidVersion, cpd.AppVersion, req.ExtendLeaveID)
|
||||
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, cpd.AndroidVersion, cpd.AppVersion)
|
||||
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,356 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 销假(POST /api/leave/execute)端到端契约测试。
|
||||
//
|
||||
// 覆盖 ExecuteLeave 在真正触达学校端之前的全部前置校验分支:
|
||||
// 参数校验 → 用户存在 → Cookie 登录态 → 设备码 → 学校 flavor 分流。
|
||||
// 这些分支不需要真实 sdau 账号,也不产生任何外网请求。
|
||||
//
|
||||
// 需要测试库(同 INVITE_IT_DSN 约定,请指向一次性库,会建表/清表):
|
||||
//
|
||||
// LEAVE_IT_DSN='root:test@tcp(127.0.0.1:13306)/leavetest?charset=utf8mb4&parseTime=True&loc=Local' \
|
||||
// go test ./internal/handler/ -run ExecuteLeave -count=1 -v
|
||||
//
|
||||
// 未覆盖:扣额度与学校端真实提交(依赖真实 cookie、待销假记录与学校业务校验)。
|
||||
|
||||
const testCookieJSON = `{"MOD_AUTH_CAS":"ST-test"}`
|
||||
|
||||
// setupLeaveIT 用测试库接管 database.DB,并重建 SysUser 表。
|
||||
func setupLeaveIT(t *testing.T) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("LEAVE_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 LEAVE_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)
|
||||
}
|
||||
prev := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = prev })
|
||||
}
|
||||
|
||||
// newLeaveApp 构造只挂 ExecuteLeave 的最小 app,模拟 AuthRequired 已放行并写入 userId。
|
||||
func newLeaveApp(userID uint) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/leave/execute", func(c *fiber.Ctx) error {
|
||||
c.Locals("userId", userID)
|
||||
return ExecuteLeave(c)
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
// doLeave 发起一次销假请求,返回状态码与响应体。
|
||||
func doLeave(t *testing.T, app *fiber.App, body string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/api/leave/execute", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取响应失败: %v", err)
|
||||
}
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
// createLeaveUser 建一个带 Cookie / 设备码 / 学校的用户(默认 0 次、非 VIP)。
|
||||
func createLeaveUser(t *testing.T, username, schoolID, cookie, mobileCode string) model.SysUser {
|
||||
t.Helper()
|
||||
return createLeaveUserFull(t, username, schoolID, cookie, mobileCode, 0, nil)
|
||||
}
|
||||
|
||||
// createLeaveUserFull 额外指定可执行次数与 VIP 到期时间(nil 表示非 VIP)。
|
||||
func createLeaveUserFull(t *testing.T, username, schoolID, cookie, mobileCode string, signCount float64, vipExpire *time.Time) model.SysUser {
|
||||
t.Helper()
|
||||
u := model.SysUser{
|
||||
Username: username,
|
||||
Password: "x",
|
||||
SchoolID: schoolID,
|
||||
SchoolName: "测试学校",
|
||||
StudentID: username,
|
||||
Cookie: cookie,
|
||||
MobileCode: mobileCode,
|
||||
SignCount: signCount,
|
||||
VipExpireAt: vipExpire,
|
||||
}
|
||||
if err := database.DB.Create(&u).Error; err != nil {
|
||||
t.Fatalf("插入用户失败: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// setDeduct 写入销假扣减额度系统设置。
|
||||
func setDeduct(t *testing.T, reportValue string) {
|
||||
t.Helper()
|
||||
if err := database.DB.AutoMigrate(&model.SystemConfig{}); err != nil {
|
||||
t.Fatalf("建 SystemConfig 表失败: %v", err)
|
||||
}
|
||||
cfg := model.SystemConfig{Key: settingDeductReport, Value: reportValue}
|
||||
if err := database.DB.Create(&cfg).Error; err != nil {
|
||||
t.Fatalf("写入扣减额度失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺 LeaveID / 经纬度 时必须 400 且不查库、不发外部请求。
|
||||
func TestExecuteLeaveRejectsMissingParams(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p1", "ynu", testCookieJSON, "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"全空", `{}`, "销假经纬度不能为空"},
|
||||
{"缺经纬度", `{"leaveId":"L1"}`, "销假经纬度不能为空"},
|
||||
{"缺经度", `{"leaveId":"L1","latitude":"25.0"}`, "销假经纬度不能为空"},
|
||||
{"缺纬度", `{"leaveId":"L1","longitude":"102.7"}`, "销假经纬度不能为空"},
|
||||
{"只有空白字符", `{"leaveId":" ","longitude":" ","latitude":" "}`, "销假经纬度不能为空"},
|
||||
{"非法 JSON", `{`, "请求参数错误"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
code, body := doLeave(t, app, c.body)
|
||||
if code != 400 {
|
||||
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, c.want) {
|
||||
t.Fatalf("响应缺少 %q: %s", c.want, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 用户不存在时必须 401,而不是 500。
|
||||
func TestExecuteLeaveRejectsUnknownUser(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
app := newLeaveApp(999999)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 401 {
|
||||
t.Fatalf("状态码 = %d, want 401; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "用户不存在") {
|
||||
t.Fatalf("响应缺少提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie 为空时必须提示重新登录,不得继续走学校端。
|
||||
func TestExecuteLeaveRejectsEmptyCookie(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p2", "ynu", "", "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 400 {
|
||||
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "请先登录") {
|
||||
t.Fatalf("响应缺少提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 设备码为空时必须拦截,避免学校端风控异常。
|
||||
func TestExecuteLeaveRejectsEmptyMobileCode(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p3", "ynu", testCookieJSON, "")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 400 {
|
||||
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "绑定设备码") {
|
||||
t.Fatalf("响应缺少提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// sdau 属于独立学工系统:销假必须分流到学工系统,不得落到辅导猫分支。
|
||||
// 这是 sdau 销假最关键的分流契约——走错分支会让用户看到辅导猫的 420「产品已到期」。
|
||||
//
|
||||
// 学工销假同样是 SSE 流:先发 connecting,会话失败再发 task_error。
|
||||
// 测试环境无真实学校网络,故断言「流内出现学工系统文案且不出现辅导猫」。
|
||||
func TestExecuteLeaveRoutesXsgzSchoolAwayFromCounselor(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p4", "sdau", testCookieJSON, "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
|
||||
if code != 200 {
|
||||
t.Fatalf("SSE 流状态码应为 200,实际 = %d; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("应发出 connecting 事件: %s", body)
|
||||
}
|
||||
// 没有真实 cookie,会话必失败 → 必须收到 task_error 且文案指向学工系统。
|
||||
if !strings.Contains(body, "event: task_error") {
|
||||
t.Fatalf("无有效会话时应发 task_error: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "学工系统") {
|
||||
t.Fatalf("错误文案应指向学工系统而非辅导猫: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "辅导猫") {
|
||||
t.Fatalf("sdau 不应走辅导猫分支: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 校验顺序:参数错误必须优先于查库,避免无效请求打到数据库。
|
||||
func TestExecuteLeaveParamCheckedBeforeDatabase(t *testing.T) {
|
||||
// 不建表:若先查库会因表不存在而 500。
|
||||
dsn := os.Getenv("LEAVE_IT_DSN_NO_MIGRATE")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 LEAVE_IT_DSN_NO_MIGRATE,跳过校验顺序测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
_ = db.Migrator().DropTable(&model.SysUser{})
|
||||
prev := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() {
|
||||
database.DB = prev
|
||||
_ = db.Migrator().CreateTable(&model.SysUser{})
|
||||
})
|
||||
|
||||
app := newLeaveApp(1)
|
||||
code, body := doLeave(t, app, `{}`)
|
||||
if code != 400 {
|
||||
t.Fatalf("参数校验应先于查库,状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "销假经纬度不能为空") {
|
||||
t.Fatalf("响应缺少参数校验提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 响应必须是统一 JSON 结构(code/message),便于前端统一处理。
|
||||
func TestExecuteLeaveErrorResponseShape(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p5", "ynu", testCookieJSON, "")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
_, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v; body=%s", err, body)
|
||||
}
|
||||
if resp.Code != 400 || resp.Message == "" {
|
||||
t.Fatalf("响应结构不符合契约: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 VIP 且次数不足时必须拒绝,且不得触达学校端。
|
||||
// 销假默认扣 1 次,用户 0 次 → 流内应发 task_error「可用次数不足」。
|
||||
func TestExecuteLeaveRejectsInsufficientSignCount(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p6", "ynu", testCookieJSON, "dev-1") // 0 次、非 VIP
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("SSE 流状态码应为 200,实际 = %d; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "event: task_error") {
|
||||
t.Fatalf("次数不足应发 task_error: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("错误文案应提示次数不足: %s", body)
|
||||
}
|
||||
// 不得进入学校端连接阶段
|
||||
if strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("次数不足时不应继续连接学校端: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 额度为 0 时(后台可配)即使 0 次也应放行,验证「0 = 不扣费」语义。
|
||||
func TestExecuteLeaveZeroDeductAllowsZeroCount(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
setDeduct(t, "0")
|
||||
u := createLeaveUser(t, "p7", "ynu", testCookieJSON, "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
|
||||
}
|
||||
if strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("额度 0 时不应拦截: %s", body)
|
||||
}
|
||||
// 0 次但额度 0 → 放行,进入连接学校端阶段
|
||||
if !strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("额度 0 时应继续连接学校端: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// VIP(未过期)免扣次数:0 次也应放行。
|
||||
func TestExecuteLeaveVipBypassesSignCount(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
u := createLeaveUserFull(t, "p8", "ynu", testCookieJSON, "dev-1", 0, &future)
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
|
||||
}
|
||||
if strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("VIP 应免扣次数: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("VIP 应继续连接学校端: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// VIP 已过期则不免扣:0 次时仍应被拦截。
|
||||
func TestExecuteLeaveExpiredVipStillDeducts(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
u := createLeaveUserFull(t, "p9", "ynu", testCookieJSON, "dev-1", 0, &past)
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("VIP 过期应照常扣次数: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -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,10 +6,36 @@ import (
|
||||
"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
|
||||
@@ -31,15 +57,28 @@ 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)
|
||||
}
|
||||
@@ -65,9 +104,12 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"base_deduction_per_run": true,
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"register_reward": true,
|
||||
"vip_price_week": true,
|
||||
"vip_price_month": true,
|
||||
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
|
||||
settingDeductCheckin: true,
|
||||
settingDeductLeave: true,
|
||||
settingDeductReport: true,
|
||||
// 邮件通知(SMTP)
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
@@ -77,11 +119,40 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"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 {
|
||||
|
||||
@@ -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,236 @@
|
||||
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)+"后再试")
|
||||
}
|
||||
|
||||
// 手机号维度 60 秒冷却:今日校园对同一号码有服务端频率限制,超频时接口
|
||||
// 仍返回 errCode=0 但短信不会下发(前端显示成功、手机收不到),故本地拦截。
|
||||
if ok, wait := service.AllowSmsCodeSend(phone); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
// 上游发送失败不占用冷却,允许用户立即重试
|
||||
service.ReleaseSmsCodeCooldown(phone)
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
countdown := result.Countdown
|
||||
if countdown < service.SmsCodeCooldownSeconds {
|
||||
countdown = service.SmsCodeCooldownSeconds
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": 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 回滚,探针不落库
|
||||
}
|
||||
+418
-41
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
@@ -36,7 +37,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)
|
||||
}
|
||||
|
||||
@@ -148,6 +162,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, "用户不存在")
|
||||
@@ -165,12 +222,14 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
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())
|
||||
@@ -242,10 +301,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 简单通配符匹配(仅支持 *)
|
||||
@@ -295,7 +364,9 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
database.DB.Model(&model.CpTaskLog{}).
|
||||
// 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)
|
||||
@@ -315,6 +386,8 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
// 尝试获取今日任务进行匹配
|
||||
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 != "" {
|
||||
@@ -345,12 +418,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 匹配(支持 * 通配符)
|
||||
@@ -374,6 +481,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)
|
||||
@@ -387,7 +513,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")
|
||||
@@ -400,6 +527,53 @@ 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, "任务正在执行中")
|
||||
}
|
||||
@@ -414,6 +588,11 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
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")
|
||||
@@ -446,9 +625,12 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||
// 本次打卡扣减额度(后台可配置,允许小数与 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
|
||||
}
|
||||
@@ -466,6 +648,18 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户同一任务串行执行。
|
||||
// 限流只约束频率,挡不住两个并发请求同时通过限流后向学校端重复提交
|
||||
// (多实例部署时尤其明显,各实例各自放行)。这里用跨实例互斥锁覆盖整个执行流程。
|
||||
// TTL 须大于最长执行耗时(含人脸复核轮询),否则锁提前过期会失去互斥意义。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:task:%d:%d", userID, task.ID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("同一任务正在执行中,拒绝重复执行", "userId", userID, "taskId", id)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "该任务正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
// 所有前置检查通过,更新状态为执行中
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
@@ -486,22 +680,27 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
// 续期成功但仍过期:不能让原始 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
|
||||
}
|
||||
|
||||
@@ -512,7 +711,40 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
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 := "表单数据解析失败"
|
||||
@@ -528,9 +760,24 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
// 4. 填充表单并提交
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
|
||||
|
||||
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
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": "正在上传照片..."})
|
||||
@@ -547,6 +794,16 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
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) {
|
||||
@@ -554,25 +811,99 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
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",
|
||||
@@ -581,11 +912,15 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
|
||||
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
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": "签到成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
@@ -705,19 +1040,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())
|
||||
@@ -749,19 +1105,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,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"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
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户同一返校登记任务串行执行,避免并发重复提交。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:return:%d:%d", userID, task.ID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("返校登记正在执行中,拒绝重复提交", "userId", userID, "taskId", task.ID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "该任务正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
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,163 @@
|
||||
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
|
||||
}
|
||||
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
|
||||
place, matched := cpd.WorklogMatchedScope(*f, lat, lon)
|
||||
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !matched {
|
||||
msg := "工作日志「" + f.Title + "」当前定位不在允许的签到范围内,请到指定校区后重新获取定位"
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
// signPlace 必须是命中的签到点名称(学校配置的 address),空串会导致学校端
|
||||
// 校验「信息收集信息」不通过并返回 2210010000。
|
||||
signPlace := place.Address
|
||||
// deviceId 用 MobileCode、username 用 StudentID,与普通签到提交保持一致
|
||||
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, signPlace, lon, lat, user.MobileCode, user.StudentID); 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])
|
||||
}
|
||||
@@ -37,6 +37,8 @@ type TicketListItem struct {
|
||||
MessageCount int64 `json:"messageCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
// 管理员最近一次操作后用户是否已读;false 时用户端列表显示未读红点
|
||||
UserRead bool `json:"userRead"`
|
||||
}
|
||||
|
||||
type AdminTicketListItem struct {
|
||||
@@ -44,6 +46,12 @@ type AdminTicketListItem struct {
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
|
||||
Deleted bool `json:"deleted"`
|
||||
// 管理员最近一次操作后用户是否已读;false 表示用户还没看这条回复/状态变更
|
||||
UserRead bool `json:"userRead"`
|
||||
// 用户最近一次回复后管理员是否已读;false 表示后台该工单显示未读红点
|
||||
AdminRead bool `json:"adminRead"`
|
||||
}
|
||||
|
||||
type TicketDetail struct {
|
||||
@@ -55,6 +63,10 @@ type TicketDetail struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Messages []TicketMessageRes `json:"messages"`
|
||||
// 管理员最近一次操作(回复/状态变更)后,用户是否已读
|
||||
UserRead bool `json:"userRead"`
|
||||
// 用户最近一次回复后,管理员是否已读
|
||||
AdminRead bool `json:"adminRead"`
|
||||
}
|
||||
|
||||
type TicketMessageRes struct {
|
||||
@@ -69,18 +81,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,
|
||||
}
|
||||
@@ -148,6 +171,7 @@ func ListMyTickets(c *fiber.Ctx) error {
|
||||
MessageCount: count,
|
||||
CreatedAt: t.CreatedAt.Format("2006-01-02 15:04"),
|
||||
UpdatedAt: t.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
UserRead: t.UserRead,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,6 +194,13 @@ func GetTicketDetail(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 用户打开工单即视为已读:清掉工单级未读红点。
|
||||
// 逐条通知的 is_read 由通知列表单独维护,两者互不影响。
|
||||
if !ticket.UserRead {
|
||||
database.DB.Model(&ticket).Update("user_read", true)
|
||||
ticket.UserRead = true
|
||||
}
|
||||
|
||||
var messages []model.TicketMessage
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
|
||||
|
||||
@@ -194,9 +225,46 @@ func GetTicketDetail(c *fiber.Ctx) error {
|
||||
CreatedAt: ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
UpdatedAt: ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
Messages: msgRes,
|
||||
UserRead: ticket.UserRead,
|
||||
AdminRead: ticket.AdminRead,
|
||||
})
|
||||
}
|
||||
|
||||
// UserReopenTicket 用户把「已完成」工单打回「处理中」。
|
||||
// 仅 resolved 可打回:closed 是管理员终结状态,pending 本就是待处理。
|
||||
// 打回即视为用户表达「问题没解决」,因此管理员侧置为未读。
|
||||
func UserReopenTicket(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
ticketID, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
return utils.FailParam(c, "无效的工单ID")
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
if ticket.Status == "closed" {
|
||||
return utils.Fail(c, 400, "工单已关闭,无法打回")
|
||||
}
|
||||
if ticket.Status != "resolved" {
|
||||
return utils.Fail(c, 400, "只有已完成的工单可以打回")
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&ticket).Updates(map[string]interface{}{
|
||||
"status": "processing",
|
||||
"admin_read": false,
|
||||
}).Error; err != nil {
|
||||
return utils.FailServer(c, "打回失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"status": "processing"})
|
||||
}
|
||||
|
||||
// AddTicketMessage 用户回复工单
|
||||
func AddTicketMessage(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
@@ -237,10 +305,22 @@ func AddTicketMessage(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "回复失败")
|
||||
}
|
||||
|
||||
// 如果工单是已解决状态,回复后重新开启
|
||||
// 用户回复后:管理员侧重新变为未读(后台列表显示红点)。
|
||||
// 同时把「已完成」打回「处理中」——用户回复即表示问题未真正解决,需管理员继续跟进。
|
||||
// pending/processing 状态保持不变;closed 已在上方拦截。
|
||||
updates := map[string]interface{}{"admin_read": false}
|
||||
if ticket.Status == "resolved" {
|
||||
database.DB.Model(&ticket).Update("status", "pending")
|
||||
updates["status"] = "processing"
|
||||
}
|
||||
database.DB.Model(&ticket).Updates(updates)
|
||||
|
||||
// 异步邮件提醒管理员:工单对话有新回复
|
||||
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,
|
||||
@@ -272,7 +352,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")
|
||||
@@ -288,12 +372,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 ----------
|
||||
@@ -305,15 +393,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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,11 +450,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")
|
||||
@@ -389,6 +494,9 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
UserRead: r.UserRead,
|
||||
AdminRead: r.AdminRead,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +511,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, "工单不存在")
|
||||
}
|
||||
@@ -414,6 +523,13 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
var user model.SysUser
|
||||
database.DB.Select("username, real_name, school_id").First(&user, ticket.UserID)
|
||||
|
||||
// 管理员打开工单即视为已读:清掉后台「用户有新回复」红点。
|
||||
// 用户的未读(user_read)不受影响——管理员看没看,不应替用户决定。
|
||||
if !ticket.AdminRead {
|
||||
database.DB.Unscoped().Model(&model.Ticket{}).Where("id = ?", ticket.ID).Update("admin_read", true)
|
||||
ticket.AdminRead = true
|
||||
}
|
||||
|
||||
// 获取消息
|
||||
var messages []model.TicketMessage
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
|
||||
@@ -441,6 +557,9 @@ 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,
|
||||
"userRead": ticket.UserRead,
|
||||
"adminRead": ticket.AdminRead,
|
||||
"messages": msgRes,
|
||||
})
|
||||
}
|
||||
@@ -462,7 +581,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, "工单不存在")
|
||||
}
|
||||
@@ -483,15 +603,19 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "回复失败")
|
||||
}
|
||||
|
||||
// 管理员回复后自动将状态改为处理中
|
||||
// 管理员回复后:对用户置为未读(用户需要看到这条回复),
|
||||
// 并把待处理推进到处理中。admin_read 置 true——这是管理员自己的动作,
|
||||
// 后台不应因此把自己标成未读。
|
||||
adminUpdates := map[string]interface{}{"user_read": false, "admin_read": true}
|
||||
if ticket.Status == "pending" {
|
||||
database.DB.Model(&ticket).Update("status", "processing")
|
||||
adminUpdates["status"] = "processing"
|
||||
}
|
||||
database.DB.Model(&ticket).Updates(adminUpdates)
|
||||
|
||||
// 发送通知给用户
|
||||
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,
|
||||
@@ -521,25 +645,32 @@ 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, "工单不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
database.DB.Model(&ticket).Update("status", req.Status)
|
||||
// 管理员改状态:对用户置为未读(用户需要知道状态变了)。
|
||||
// admin_read 置 true——这是管理员自己的操作,后台不显示未读。
|
||||
database.DB.Model(&ticket).Updates(map[string]interface{}{
|
||||
"status": req.Status,
|
||||
"user_read": false,
|
||||
"admin_read": true,
|
||||
})
|
||||
|
||||
// 发送通知给用户
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 工单双向未读契约测试。
|
||||
//
|
||||
// 覆盖三个方向的状态迁移:
|
||||
// 管理员回复/改状态 → 用户侧未读(user_read=false),管理员侧已读(admin_read=true)
|
||||
// 用户回复 → 管理员侧未读(admin_read=false)
|
||||
// 打开详情 → 对应一侧置为已读
|
||||
// 以及用户打回的边界(仅 resolved → processing)。
|
||||
//
|
||||
// 需要测试库(同 LEAVE_IT_DSN 约定,指向一次性库,会建表/清表):
|
||||
//
|
||||
// TICKET_IT_DSN='root:test@tcp(127.0.0.1:13306)/tickettest?charset=utf8mb4&parseTime=True&loc=Local' \
|
||||
// go test ./internal/handler/ -run Ticket -count=1 -v
|
||||
|
||||
func setupTicketIT(t *testing.T) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TICKET_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 TICKET_IT_DSN,跳过工单数据库集成测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
for _, m := range []interface{}{&model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}} {
|
||||
if err := db.Migrator().DropTable(m); err != nil {
|
||||
t.Fatalf("清表失败: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SysUser{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
prev := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = prev })
|
||||
}
|
||||
|
||||
// newTicketApp 构造最小 app,按需注入 userId / adminId。
|
||||
func newTicketApp(userID, adminID uint) *fiber.App {
|
||||
app := fiber.New()
|
||||
withUser := func(c *fiber.Ctx) error {
|
||||
c.Locals("userId", userID)
|
||||
return c.Next()
|
||||
}
|
||||
withAdmin := func(c *fiber.Ctx) error {
|
||||
c.Locals("adminId", adminID)
|
||||
return c.Next()
|
||||
}
|
||||
app.Get("/ticket/:id", withUser, GetTicketDetail)
|
||||
app.Post("/ticket/:id/message", withUser, AddTicketMessage)
|
||||
app.Put("/ticket/:id/reopen", withUser, UserReopenTicket)
|
||||
app.Get("/admin/ticket/:id", withAdmin, AdminGetTicket)
|
||||
app.Post("/admin/ticket/reply/:id", withAdmin, AdminReplyTicket)
|
||||
app.Put("/admin/ticket/status/:id", withAdmin, AdminUpdateTicketStatus)
|
||||
return app
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
func createTicket(t *testing.T, userID uint, status string) model.Ticket {
|
||||
t.Helper()
|
||||
tk := model.Ticket{
|
||||
UserID: userID,
|
||||
Title: "测试工单",
|
||||
Content: "内容",
|
||||
Status: status,
|
||||
UserRead: true,
|
||||
AdminRead: true,
|
||||
}
|
||||
if err := database.DB.Create(&tk).Error; err != nil {
|
||||
t.Fatalf("建工单失败: %v", err)
|
||||
}
|
||||
return tk
|
||||
}
|
||||
|
||||
// reloadTicket 从库里重读工单,确认落库结果。
|
||||
func reloadTicket(t *testing.T, id uint) model.Ticket {
|
||||
t.Helper()
|
||||
var tk model.Ticket
|
||||
if err := database.DB.Unscoped().First(&tk, id).Error; err != nil {
|
||||
t.Fatalf("重读工单失败: %v", err)
|
||||
}
|
||||
return tk
|
||||
}
|
||||
|
||||
// 管理员回复:用户侧变未读,管理员侧保持已读。
|
||||
func TestTicketAdminReplyMarksUserUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "POST", "/admin/ticket/reply/"+itoa(tk.ID), `{"content":"已处理,请查收"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("回复失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.UserRead {
|
||||
t.Fatalf("管理员回复后用户侧应为未读")
|
||||
}
|
||||
if !got.AdminRead {
|
||||
t.Fatalf("管理员自己的回复不应把后台标为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员改状态:用户侧变未读,管理员侧已读。
|
||||
func TestTicketAdminStatusChangeMarksUserUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "PUT", "/admin/ticket/status/"+itoa(tk.ID), `{"status":"resolved"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("改状态失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.UserRead {
|
||||
t.Fatalf("状态变更后用户侧应为未读")
|
||||
}
|
||||
if !got.AdminRead {
|
||||
t.Fatalf("管理员自己的操作不应把后台标为未读")
|
||||
}
|
||||
if got.Status != "resolved" {
|
||||
t.Fatalf("状态应为 resolved,实际 %s", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户回复:管理员侧变未读;user_read 不受影响(管理员没做新动作)。
|
||||
func TestTicketUserReplyMarksAdminUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "POST", "/ticket/"+itoa(tk.ID)+"/message", `{"content":"还是不行"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("回复失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.AdminRead {
|
||||
t.Fatalf("用户回复后管理员侧应为未读")
|
||||
}
|
||||
if !got.UserRead {
|
||||
t.Fatalf("用户自己的回复不应把用户侧标为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 用户对已完成工单回复:自动打回处理中 + 管理员未读。
|
||||
func TestTicketUserReplyReopensResolved(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "resolved")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "POST", "/ticket/"+itoa(tk.ID)+"/message", `{"content":"问题还在"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("回复失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.Status != "processing" {
|
||||
t.Fatalf("已完成工单被回复后应打回 processing,实际 %s", got.Status)
|
||||
}
|
||||
if got.AdminRead {
|
||||
t.Fatalf("用户回复后管理员侧应为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 用户打开详情:user_read 置真。
|
||||
func TestTicketUserOpenMarksUserRead(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Update("user_read", false)
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "GET", "/ticket/"+itoa(tk.ID), "")
|
||||
if code != 200 {
|
||||
t.Fatalf("查询失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
if !reloadTicket(t, tk.ID).UserRead {
|
||||
t.Fatalf("用户打开详情后应标记为已读")
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员打开详情:admin_read 置真。
|
||||
func TestTicketAdminOpenMarksAdminRead(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Update("admin_read", false)
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "GET", "/admin/ticket/"+itoa(tk.ID), "")
|
||||
if code != 200 {
|
||||
t.Fatalf("查询失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
if !reloadTicket(t, tk.ID).AdminRead {
|
||||
t.Fatalf("管理员打开详情后应标记为已读")
|
||||
}
|
||||
}
|
||||
|
||||
// 打回:仅 resolved 允许,且有次数与状态边界。
|
||||
func TestTicketReopenBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fromStatus string
|
||||
wantCode int
|
||||
wantStatus string
|
||||
}{
|
||||
{"已完成可打回", "resolved", 200, "processing"},
|
||||
{"处理中不可打回", "processing", 400, "processing"},
|
||||
{"待处理不可打回", "pending", 400, "pending"},
|
||||
{"已关闭不可打回", "closed", 400, "closed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, c.fromStatus)
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "PUT", "/ticket/"+itoa(tk.ID)+"/reopen", "")
|
||||
if code != c.wantCode {
|
||||
t.Fatalf("状态码 = %d, want %d; body=%s", code, c.wantCode, body)
|
||||
}
|
||||
if got := reloadTicket(t, tk.ID).Status; got != c.wantStatus {
|
||||
t.Fatalf("状态 = %s, want %s", got, c.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打回后管理员侧应为未读(用户表达了「问题没解决」)。
|
||||
func TestTicketReopenMarksAdminUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "resolved")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "PUT", "/ticket/"+itoa(tk.ID)+"/reopen", "")
|
||||
if code != 200 {
|
||||
t.Fatalf("打回失败 code=%d body=%s", code, body)
|
||||
}
|
||||
if reloadTicket(t, tk.ID).AdminRead {
|
||||
t.Fatalf("打回后管理员侧应为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 详情接口要把两个已读标记返回给前端。
|
||||
func TestTicketDetailExposesReadFlags(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Updates(map[string]interface{}{
|
||||
"user_read": false, "admin_read": false,
|
||||
})
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
// 用户详情:调用后 user_read 会被置真
|
||||
_, body := doReq(t, app, "GET", "/ticket/"+itoa(tk.ID), "")
|
||||
// 响应外层是统一结构 {code,message,data},字段在 data 里
|
||||
var resp struct {
|
||||
Data struct {
|
||||
UserRead bool `json:"userRead"`
|
||||
AdminRead bool `json:"adminRead"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v; body=%s", err, body)
|
||||
}
|
||||
if !resp.Data.UserRead {
|
||||
t.Fatalf("用户打开详情后返回的 userRead 应为 true,body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v uint) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for v > 0 {
|
||||
b = append([]byte{byte('0' + v%10)}, b...)
|
||||
v /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -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. 已读数
|
||||
@@ -185,6 +219,11 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 404, "公告不存在")
|
||||
}
|
||||
|
||||
// 置顶公告不进入用户公告列表,不可被标记已读
|
||||
if announcement.IsPinned {
|
||||
return utils.Fail(c, 400, "置顶公告无需标记已读")
|
||||
}
|
||||
|
||||
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
|
||||
if announcement.TargetType != userType {
|
||||
return utils.Fail(c, 403, "无权操作此公告")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package lock 提供基于 Redis 的分布式互斥锁。
|
||||
//
|
||||
// 用途:阻止同一用户在不同实例 / 不同浏览器上并发触发同一个不可重入的操作
|
||||
// (签到提交、请假申请、销假等)。单机时代这类并发靠进程内 mutex 就够,
|
||||
// 多实例部署后必须靠 Redis 才能跨进程互斥。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 锁值用随机 token,释放时必须校验 token 一致(Lua 原子比较删除),
|
||||
// 否则 A 的锁过期后 B 拿到锁,A 执行完把 B 的锁删了。
|
||||
// - 默认不自动续期:这些业务操作都是有界的 HTTP/爬虫请求,耗时已知且较短,
|
||||
// 给足 TTL 即可。自动续期会引入后台 goroutine 与泄漏风险,不值得。
|
||||
// - Redis 不可用时降级为进程内锁:单机能保证幂等,多实例降级期间不保证。
|
||||
// 这比让 Redis 抖动直接拒绝所有请求更合理。
|
||||
package lock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// releaseScript 仅当锁值匹配时才删除(原子),防止误删他人锁
|
||||
var releaseScript = redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// extendScript 仅当锁值匹配时才续期
|
||||
var extendScript = redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// 前缀隔离,避免与其他业务的 Redis key 冲突
|
||||
const keyPrefix = "lock:"
|
||||
|
||||
// Lock 表示一把已获取的锁
|
||||
type Lock struct {
|
||||
key string
|
||||
token string
|
||||
// 内存降级模式下持有的互斥键
|
||||
localKey string
|
||||
}
|
||||
|
||||
// TryLock 尝试获取锁,不等待。
|
||||
// 返回 (lock, true) 表示获取成功;返回 (nil, false) 表示已被他人持有。
|
||||
// ttl 是锁的自动过期时间,必须大于业务操作的最长耗时,否则会出现"重复执行"。
|
||||
func TryLock(key string, ttl time.Duration) (*Lock, bool) {
|
||||
fullKey := keyPrefix + key
|
||||
token := randomToken()
|
||||
|
||||
if service.Redis != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
ok, err := service.Redis.SetNX(ctx, fullKey, token, ttl).Result()
|
||||
if err == nil {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &Lock{key: fullKey, token: token}, true
|
||||
}
|
||||
// Redis 异常,落到内存锁
|
||||
}
|
||||
|
||||
if !localMutex.tryAcquire(key) {
|
||||
return nil, false
|
||||
}
|
||||
return &Lock{localKey: key}, true
|
||||
}
|
||||
|
||||
// Acquire 获取锁,最多等待 wait 时间。
|
||||
// 返回 (lock, true) 表示获取成功;超时返回 (nil, false)。
|
||||
func Acquire(key string, ttl, wait time.Duration) (*Lock, bool) {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
if l, ok := TryLock(key, ttl); ok {
|
||||
return l, true
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, false
|
||||
}
|
||||
// 短间隔轮询:业务锁争抢是低频事件,不需要指数退避
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock 释放锁。必须由获取锁的一方调用,且调用后不应再使用该锁对象。
|
||||
func (l *Lock) Unlock() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
if l.localKey != "" {
|
||||
localMutex.release(l.localKey)
|
||||
return
|
||||
}
|
||||
if service.Redis == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := releaseScript.Run(ctx, service.Redis, []string{l.key}, l.token).Err(); err != nil {
|
||||
// 释放失败不致命:TTL 到期会自动删除
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Extend 续期。仅当仍持有该锁时生效,返回是否续期成功。
|
||||
// 长耗时操作可在中途调用;普通短操作不需要。
|
||||
func (l *Lock) Extend(ttl time.Duration) bool {
|
||||
if l == nil || l.localKey != "" || service.Redis == nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
n, err := extendScript.Run(ctx, service.Redis, []string{l.key}, l.token, ttl.Milliseconds()).Int64()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// randomToken 生成锁持有者标识
|
||||
func randomToken() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// 极端情况下退化为时间戳,仍可用(只是碰撞概率略高)
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// ---------- 内存降级锁 ----------
|
||||
|
||||
type localLocks struct {
|
||||
mu sync.Mutex
|
||||
held map[string]bool
|
||||
}
|
||||
|
||||
var localMutex = &localLocks{held: make(map[string]bool)}
|
||||
|
||||
func (m *localLocks) tryAcquire(key string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.held[key] {
|
||||
return false
|
||||
}
|
||||
m.held[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *localLocks) release(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.held, key)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package lock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func testRedisAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv("TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func withRedis(t *testing.T) {
|
||||
t.Helper()
|
||||
addr := testRedisAddr(t)
|
||||
c := redis.NewClient(&redis.Options{Addr: addr})
|
||||
if err := c.Ping(context.Background()).Err(); err != nil {
|
||||
t.Fatalf("连接测试 Redis 失败: %v", err)
|
||||
}
|
||||
old := service.Redis
|
||||
service.Redis = c
|
||||
t.Cleanup(func() {
|
||||
service.Redis = old
|
||||
_ = c.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func withoutRedis(t *testing.T) {
|
||||
t.Helper()
|
||||
old := service.Redis
|
||||
service.Redis = nil // 强制走内存降级
|
||||
t.Cleanup(func() { service.Redis = old })
|
||||
}
|
||||
|
||||
func uniqKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fmt.Sprintf("test:%s:%d", t.Name(), time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// ---------- 内存降级模式 ----------
|
||||
|
||||
func TestTryLock_Local_MutualExclusion(t *testing.T) {
|
||||
withoutRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("首次应获取成功")
|
||||
}
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("已持有时第二次应失败")
|
||||
}
|
||||
|
||||
a.Unlock()
|
||||
if _, ok := TryLock(key, time.Minute); !ok {
|
||||
t.Fatal("释放后应可再次获取")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Redis 模式 ----------
|
||||
|
||||
func TestTryLock_Redis_MutualExclusion(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("首次应获取成功")
|
||||
}
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("已持有时第二次应失败")
|
||||
}
|
||||
|
||||
a.Unlock()
|
||||
b, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("释放后应可再次获取")
|
||||
}
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
// 锁 TTL 到期后应自动可被他人获取(防止进程崩溃后锁永久卡死)
|
||||
func TestTryLock_Redis_TTLExpiry(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
if _, ok := TryLock(key, 200*time.Millisecond); !ok {
|
||||
t.Fatal("首次应获取成功")
|
||||
}
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
if _, ok := TryLock(key, time.Minute); !ok {
|
||||
t.Fatal("TTL 到期后应可被获取")
|
||||
}
|
||||
}
|
||||
|
||||
// 关键用例:A 的锁过期后 B 拿到锁,A 迟到的 Unlock 绝不能删掉 B 的锁,
|
||||
// 否则会出现「两个请求同时认为自己持有锁」的重复执行。
|
||||
func TestUnlock_DoesNotDeleteOthersLock(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, 200*time.Millisecond)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
|
||||
// 等 A 的锁过期
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
b, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("A 过期后 B 应获取成功")
|
||||
}
|
||||
|
||||
// A 迟到的释放:token 不匹配,必须什么都不做
|
||||
a.Unlock()
|
||||
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("B 仍持有锁,第三次获取应失败(A 的 Unlock 误删了 B 的锁)")
|
||||
}
|
||||
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func TestAcquire_WaitsForRelease(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
|
||||
// 150ms 后释放,B 在 3s 内等待应能成功拿到
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
a.Unlock()
|
||||
}()
|
||||
|
||||
b, ok := Acquire(key, time.Minute, 3*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("等待期间锁释放后应获取成功")
|
||||
}
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func TestAcquire_Timeout(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
defer a.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
if _, ok := Acquire(key, time.Minute, 300*time.Millisecond); ok {
|
||||
t.Fatal("锁被长期持有时等待应超时失败")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("等待时长异常,耗时 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtend_OnlyHolderCanExtend(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, 200*time.Millisecond)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
if !a.Extend(time.Minute) {
|
||||
t.Fatal("持有者续期应成功")
|
||||
}
|
||||
|
||||
// 续期后存活时间应超过原 TTL
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("续期后锁不应已过期")
|
||||
}
|
||||
|
||||
a.Unlock()
|
||||
}
|
||||
|
||||
// 并发争抢下必须恰好只有一个赢家
|
||||
func TestTryLock_ConcurrentSingleWinner(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
winners := 0
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if l, ok := TryLock(key, 30*time.Second); ok {
|
||||
mu.Lock()
|
||||
winners++
|
||||
mu.Unlock()
|
||||
_ = l // 不释放,保持持有直到测试结束
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if winners != 1 {
|
||||
t.Fatalf("并发争抢应恰好 1 个赢家,实际 %d", winners)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,20 @@ 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,8 +35,8 @@ 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"`
|
||||
// 邀请码
|
||||
|
||||
@@ -2,17 +2,31 @@ 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"`
|
||||
// 双向已读标记:工单是用户与管理员之间的对话,两侧各自维护一个未读状态。
|
||||
// UserRead :用户是否已读「管理员最近一次操作」(回复或状态变更)之后的内容。
|
||||
// 管理员回复/改状态时置 false,用户打开工单时置 true。
|
||||
// AdminRead :管理员是否已读「用户最近一次回复」。
|
||||
// 用户回复时置 false,管理员打开工单时置 true。
|
||||
// 两者独立:管理员刚回复完,对用户是未读,对管理员自己仍是已读。
|
||||
UserRead bool `gorm:"not null;default:true;index" json:"userRead"`
|
||||
AdminRead bool `gorm:"not null;default:true;index" json:"adminRead"`
|
||||
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"`
|
||||
|
||||
@@ -1,12 +1,115 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// key 为 "userID:taskID"
|
||||
// 限流实现。
|
||||
//
|
||||
// 双轨:
|
||||
// - Redis 可用时走滑动窗口(Lua 原子脚本),多实例共享同一份计数,
|
||||
// 这是多机部署下限流正确性的前提——否则每个实例各算各的,
|
||||
// 用户把 N 个实例各点一次就绕过了「10 次/天」。
|
||||
// - Redis 未配置或调用失败时降级为进程内内存计数(单机语义与历史行为一致)。
|
||||
// 降级只影响多实例一致性,不会让功能不可用。
|
||||
//
|
||||
// 对外 API(Allow / AllowMulti)签名保持与历史一致,调用方无需改动。
|
||||
|
||||
// slidingWindowScript 滑动窗口计数(原子)。
|
||||
//
|
||||
// KEYS[1] 计数键;ARGV[1] 窗口毫秒;ARGV[2] 当前毫秒;ARGV[3] 上限
|
||||
// 返回 {允许(1/0), 需等待秒数}
|
||||
//
|
||||
// 用 ZSET 存每次请求的时间戳,先剔除窗口外的,再按剩余数量判断。
|
||||
// 相比固定窗口计数,避免了窗口边界双倍突刺。
|
||||
var slidingWindowScript = redis.NewScript(`
|
||||
local windowMs = tonumber(ARGV[1])
|
||||
local now = tonumber(ARGV[2])
|
||||
local limit = tonumber(ARGV[3])
|
||||
local member = ARGV[4]
|
||||
|
||||
local cutoff = now - windowMs
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
|
||||
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
if count < limit then
|
||||
redis.call('ZADD', KEYS[1], now, member)
|
||||
redis.call('PEXPIRE', KEYS[1], windowMs)
|
||||
return { 1, 0 }
|
||||
end
|
||||
|
||||
-- 被限流:最早一条过期时即可再次放行
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
local waitMs = windowMs
|
||||
if oldest and oldest[2] then
|
||||
waitMs = tonumber(oldest[2]) + windowMs - now
|
||||
end
|
||||
if waitMs < 1000 then waitMs = 1000 end
|
||||
local waitSec = math.ceil(waitMs / 1000)
|
||||
return { 0, waitSec }
|
||||
`)
|
||||
|
||||
// redisOpTimeout 单次限流判断的 Redis 超时。
|
||||
// 取短值:限流是执行路径上的旁路判断,不能因为 Redis 抖动把主流程拖死。
|
||||
const redisOpTimeout = 1500 * time.Millisecond
|
||||
|
||||
// seq 用于给 ZSET member 加序号,避免同一纳秒内多次请求成员相同而互相覆盖
|
||||
var seq atomic.Uint64
|
||||
|
||||
func localSeq() uint64 { return seq.Add(1) }
|
||||
|
||||
// redisAvailable 判断是否可以使用 Redis
|
||||
func redisAvailable() bool {
|
||||
return service.Redis != nil
|
||||
}
|
||||
|
||||
// Allow 检查是否允许执行,limit 次 / window 时间内
|
||||
// 返回 (true, 0) 表示允许;返回 (false, 等待秒数) 表示被限流
|
||||
func Allow(key string, limit int, window time.Duration) (bool, int) {
|
||||
if limit <= 0 {
|
||||
return true, 0
|
||||
}
|
||||
if redisAvailable() {
|
||||
if ok, wait, err := allowRedis(key, limit, window); err == nil {
|
||||
return ok, wait
|
||||
}
|
||||
// Redis 异常:降级到内存,保证功能不被限流组件拖垮
|
||||
}
|
||||
return allowLocal(key, limit, window)
|
||||
}
|
||||
|
||||
// allowRedis 走 Redis 滑动窗口
|
||||
func allowRedis(key string, limit int, window time.Duration) (bool, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
// member 用 纳秒时间戳+序号 保证同毫秒内多次请求不互相覆盖
|
||||
member := fmt.Sprintf("%d-%d", now.UnixNano(), localSeq())
|
||||
|
||||
res, err := slidingWindowScript.Run(ctx, service.Redis,
|
||||
[]string{"ratelimit:" + key},
|
||||
window.Milliseconds(), now.UnixMilli(), limit, member,
|
||||
).Int64Slice()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if len(res) < 2 {
|
||||
return false, 0, fmt.Errorf("限流脚本返回值异常: %v", res)
|
||||
}
|
||||
return res[0] == 1, int(res[1]), nil
|
||||
}
|
||||
|
||||
// ---------- 内存降级实现(与原实现语义一致) ----------
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
records map[string][]time.Time
|
||||
@@ -16,16 +119,14 @@ var instance = &limiter{
|
||||
records: make(map[string][]time.Time),
|
||||
}
|
||||
|
||||
// Allow 检查是否允许执行,limit 次 / window 时间内
|
||||
// 返回 (true, 剩余秒数) 表示允许;返回 (false, 等待秒数) 表示被限流
|
||||
func Allow(key string, limit int, window time.Duration) (bool, int) {
|
||||
// allowLocal 进程内滑动窗口(Redis 不可用时使用)
|
||||
func allowLocal(key string, limit int, window time.Duration) (bool, int) {
|
||||
instance.mu.Lock()
|
||||
defer instance.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
// 清理过期记录
|
||||
records := instance.records[key]
|
||||
var valid []time.Time
|
||||
for _, t := range records {
|
||||
@@ -35,7 +136,6 @@ func Allow(key string, limit int, window time.Duration) (bool, int) {
|
||||
}
|
||||
|
||||
if len(valid) >= limit {
|
||||
// 最早那次什么时候过期(转换为秒)
|
||||
oldest := valid[0]
|
||||
wait := int((window - now.Sub(oldest)) / time.Second)
|
||||
if wait < 1 {
|
||||
@@ -88,7 +188,7 @@ func formatWait(seconds int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 定期清理过期记录(后台 goroutine 调用)
|
||||
// Cleanup 定期清理过期记录(仅内存降级模式需要;Redis 侧靠 PEXPIRE 自动回收)
|
||||
func Cleanup(interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// testRedisAddr 从环境变量读取测试 Redis 地址;未设置时相关用例跳过,
|
||||
// 保证没有 Redis 的环境(如纯单元测试机)仍能跑其余用例。
|
||||
func testRedisAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv("TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// withRedis 把全局 service.Redis 指向 addr 并返回清理函数
|
||||
func withRedis(t *testing.T, addr string) *redis.Client {
|
||||
t.Helper()
|
||||
c := redis.NewClient(&redis.Options{Addr: addr})
|
||||
if err := c.Ping(context.Background()).Err(); err != nil {
|
||||
t.Fatalf("连接测试 Redis 失败: %v", err)
|
||||
}
|
||||
old := service.Redis
|
||||
service.Redis = c
|
||||
t.Cleanup(func() {
|
||||
service.Redis = old
|
||||
_ = c.Close()
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
func uniqKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fmt.Sprintf("test:%s:%d", t.Name(), time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// ---------- 内存降级模式 ----------
|
||||
|
||||
func TestAllowLocal_LimitAndExceed(t *testing.T) {
|
||||
old := service.Redis
|
||||
service.Redis = nil // 强制走内存降级
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
|
||||
ok, _ := Allow(key, 3, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("第 1 次应放行")
|
||||
}
|
||||
ok, _ = Allow(key, 3, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("第 2 次应放行")
|
||||
}
|
||||
ok, _ = Allow(key, 3, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("第 3 次应放行")
|
||||
}
|
||||
|
||||
ok, wait := Allow(key, 3, time.Minute)
|
||||
if ok {
|
||||
t.Fatal("第 4 次应被限流")
|
||||
}
|
||||
if wait <= 0 {
|
||||
t.Fatalf("被限流时应返回正数等待秒数,实际 %d", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowLocal_WindowExpiry(t *testing.T) {
|
||||
old := service.Redis
|
||||
service.Redis = nil
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
if ok, _ := Allow(key, 1, 150*time.Millisecond); !ok {
|
||||
t.Fatal("首次应放行")
|
||||
}
|
||||
if ok, _ := Allow(key, 1, 150*time.Millisecond); ok {
|
||||
t.Fatal("窗口内第二次应被限流")
|
||||
}
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
if ok, _ := Allow(key, 1, 150*time.Millisecond); !ok {
|
||||
t.Fatal("窗口过期后应放行")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Redis 模式:跨实例一致性 ----------
|
||||
|
||||
// 关键回归用例:多实例部署时,两个实例(两个 redis 客户端)必须共享同一份计数,
|
||||
// 否则每个实例各算各的,用户把 N 个实例各点一次就绕过了限流。
|
||||
func TestAllowRedis_SharedAcrossClients(t *testing.T) {
|
||||
addr := testRedisAddr(t)
|
||||
|
||||
clientA := redis.NewClient(&redis.Options{Addr: addr})
|
||||
clientB := redis.NewClient(&redis.Options{Addr: addr})
|
||||
defer clientA.Close()
|
||||
defer clientB.Close()
|
||||
|
||||
old := service.Redis
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
|
||||
// 实例 A 用掉 2 次配额(上限 2)
|
||||
service.Redis = clientA
|
||||
if ok, _ := Allow(key, 2, time.Minute); !ok {
|
||||
t.Fatal("实例A 第 1 次应放行")
|
||||
}
|
||||
if ok, _ := Allow(key, 2, time.Minute); !ok {
|
||||
t.Fatal("实例A 第 2 次应放行")
|
||||
}
|
||||
|
||||
// 实例 B 共享同一计数,第 3 次必须被拒
|
||||
service.Redis = clientB
|
||||
if ok, wait := Allow(key, 2, time.Minute); ok {
|
||||
t.Fatalf("实例B 第 3 次应被跨实例限流;wait=%d", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowRedis_WindowExpiry(t *testing.T) {
|
||||
addr := testRedisAddr(t)
|
||||
c := withRedis(t, addr)
|
||||
_ = c
|
||||
|
||||
key := uniqKey(t)
|
||||
if ok, _ := Allow(key, 1, 300*time.Millisecond); !ok {
|
||||
t.Fatal("首次应放行")
|
||||
}
|
||||
if ok, _ := Allow(key, 1, 300*time.Millisecond); ok {
|
||||
t.Fatal("窗口内第二次应被限流")
|
||||
}
|
||||
|
||||
time.Sleep(450 * time.Millisecond)
|
||||
|
||||
if ok, _ := Allow(key, 1, 300*time.Millisecond); !ok {
|
||||
t.Fatal("窗口过期后应放行")
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 不可达时必须降级到内存而不是报错或阻塞主流程
|
||||
func TestAllow_RedisDownFallsBackToLocal(t *testing.T) {
|
||||
// 指向一个必然拒绝连接的地址,快速失败
|
||||
c := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:1",
|
||||
MaxRetries: 0,
|
||||
DialTimeout: 200 * time.Millisecond,
|
||||
})
|
||||
old := service.Redis
|
||||
service.Redis = c
|
||||
defer func() {
|
||||
service.Redis = old
|
||||
_ = c.Close()
|
||||
}()
|
||||
|
||||
key := uniqKey(t)
|
||||
ok, _ := Allow(key, 1, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("Redis 不可用时应降级放行,而非拒绝")
|
||||
}
|
||||
if ok, _ := Allow(key, 1, time.Minute); ok {
|
||||
t.Fatal("降级到内存后仍应正常执行限流")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowMulti_ShortCircuit(t *testing.T) {
|
||||
old := service.Redis
|
||||
service.Redis = nil
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
rules := []Rule{
|
||||
{Limit: 2, Window: time.Minute, Label: "2m"},
|
||||
{Limit: 100, Window: time.Hour, Label: "1h"},
|
||||
}
|
||||
|
||||
if ok, _ := AllowMulti(key, rules); !ok {
|
||||
t.Fatal("第 1 次应放行")
|
||||
}
|
||||
if ok, _ := AllowMulti(key, rules); !ok {
|
||||
t.Fatal("第 2 次应放行")
|
||||
}
|
||||
ok, msg := AllowMulti(key, rules)
|
||||
if ok {
|
||||
t.Fatal("第 3 次应被短规则拒绝")
|
||||
}
|
||||
if msg == "" {
|
||||
t.Fatal("被限流时应返回提示文案")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
)
|
||||
|
||||
// hfnu 登录入口必须选中 amp-auth-adapter,而不是回落 WecloudUrl。
|
||||
func TestHfnuLoginEntry(t *testing.T) {
|
||||
config.Load("../../../config.toml")
|
||||
school, err := GetSchoolDetail(HfnuSchoolId)
|
||||
if err != nil {
|
||||
t.Skipf("取学校详情失败: %v", err)
|
||||
}
|
||||
t.Logf("idsUrl=%q appId=%q wecloudUrl=%q", school.IdsUrl, school.AppId, school.WecloudUrl)
|
||||
|
||||
old := getCasValidLoginUrl(school)
|
||||
now := hfnuLoginEntry(school)
|
||||
t.Logf("通用函数结果 = %q", old)
|
||||
t.Logf("专用函数结果 = %q", now)
|
||||
|
||||
if now != school.IdsUrl {
|
||||
t.Fatalf("应选中 idsUrl(%q),实际 %q", school.IdsUrl, now)
|
||||
}
|
||||
if old == now {
|
||||
t.Logf("注意:通用函数恰好也返回同一值")
|
||||
}
|
||||
}
|
||||
|
||||
// 非 amp-auth 学校不受影响,仍走通用逻辑。
|
||||
func TestHfnuLoginEntryFallback(t *testing.T) {
|
||||
s := &SchoolDetailInfo{IdsUrl: "https://foo.campusphere.net", WecloudUrl: "https://foo.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s); got != s.IdsUrl {
|
||||
t.Fatalf("campusphere 学校应走通用逻辑,实际 %q", got)
|
||||
}
|
||||
s2 := &SchoolDetailInfo{IdsUrl: "", AmpUrl: "https://bar.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s2); got != "https://bar.campusphere.net" {
|
||||
t.Fatalf("无 idsUrl 时应回落 AmpUrl,实际 %q", got)
|
||||
}
|
||||
if got := hfnuLoginEntry(nil); got != "" {
|
||||
t.Fatalf("nil 应返回空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,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:
|
||||
@@ -52,7 +56,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
fullUrl := host + urlPrefix + apiPath
|
||||
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
@@ -69,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 {
|
||||
@@ -142,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:
|
||||
@@ -150,7 +157,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
fullUrl := host + urlPrefix + apiPath
|
||||
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
@@ -169,7 +176,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -236,6 +243,36 @@ 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) {
|
||||
|
||||
@@ -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", UserAgent).
|
||||
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": AndroidVersion,
|
||||
"appVersion": AppVersion,
|
||||
"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", AndroidVersion, AppVersion)
|
||||
|
||||
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", AndroidVersion, AppVersion)
|
||||
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", AndroidVersion, AppVersion)
|
||||
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", AndroidVersion, AppVersion)
|
||||
|
||||
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", AndroidVersion, AppVersion)
|
||||
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", AndroidVersion, AppVersion)
|
||||
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", AndroidVersion, AppVersion, "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", UserAgent).
|
||||
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,173 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// BzuuSchoolId 亳州学院(pending_schools id=27)
|
||||
const BzuuSchoolId = "1021882682406205"
|
||||
|
||||
// 亳州学院 zs 主题 CAS 的固定密码加密密钥(来自 themes/zs/js/casLoginView.js)
|
||||
const (
|
||||
bzuuAesKey = "1234567890adbcde"
|
||||
bzuuAesIv = "1234567890hjlkew"
|
||||
)
|
||||
|
||||
// bzuuEncryptPassword 对齐 casLoginView.js 的 encryption():
|
||||
// AES-128-CBC + ZeroPadding,输出 base64。
|
||||
func bzuuEncryptPassword(password string) string {
|
||||
block, err := aes.NewCipher([]byte(bzuuAesKey))
|
||||
if err != nil {
|
||||
return password
|
||||
}
|
||||
data := []byte(password)
|
||||
if rem := len(data) % block.BlockSize(); rem != 0 {
|
||||
data = append(data, bytes.Repeat([]byte{0}, block.BlockSize()-rem)...)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
cipher.NewCBCEncrypter(block, []byte(bzuuAesIv)).CryptBlocks(out, data)
|
||||
return base64.StdEncoding.EncodeToString(out)
|
||||
}
|
||||
|
||||
var bzuuSessionValueRe = regexp.MustCompile(`id\s*=\s*"sessionValue"[^>]*>([^<]+)<`)
|
||||
|
||||
// bzuuCasLogin 亳州学院登录适配。
|
||||
//
|
||||
// 平台侧配置 joinType=CLOUD(IsIap=true)、idsUrl=https://bzuu.campusphere.net/iap,
|
||||
// 但真实链路是学校统一身份认证(auth.bzuu.edu.cn/authserver,zs 主题定制 CAS),
|
||||
// 且该校**不校验表单明文密码**:账号密码必须先 POST /authserver/userLogin 完成校验,
|
||||
// 密码用固定 key/iv 做 AES-CBC(ZeroPadding)+base64;校验通过后再提交登录表单换取票据。
|
||||
// 只按通用 CAS 提交明文密码会稳定返回 401「登录失败,请检查用户名和密码!」。
|
||||
func bzuuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 进入网关,跟随重定向到学校 CAS 登录页
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 访问登录页失败: %w", err)
|
||||
}
|
||||
finalURL := pageResp.RawResponse.Request.URL
|
||||
pageBody := readBody(pageResp)
|
||||
|
||||
// 2. 页面内嵌的 sessionValue 必须以 SESSION cookie 形式带上,否则表单提交被判无效
|
||||
if m := bzuuSessionValueRe.FindSubmatch(pageBody); len(m) > 1 {
|
||||
sc.SetCookie(&http.Cookie{
|
||||
Name: "SESSION", Value: strings.TrimSpace(string(m[1])),
|
||||
Path: "/", Domain: finalURL.Host,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 解析表单隐藏域(execution/_eventId/geolocation)
|
||||
inputs := parseLoginForm(pageBody, username, password)
|
||||
execution := inputs["execution"]
|
||||
eventId := inputs["_eventId"]
|
||||
geolocation := inputs["geolocation"]
|
||||
|
||||
// 4. 加密密码并调用 userLogin 完成账号密码校验
|
||||
encPwd := bzuuEncryptPassword(password)
|
||||
base := fmt.Sprintf("%s://%s", finalURL.Scheme, finalURL.Host)
|
||||
ulResp, err := sc.R().
|
||||
SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"serviceUrl": finalURL.String(),
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"loginCaptcha": "",
|
||||
}).
|
||||
Post(base + "/authserver/userLogin")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 提交账号密码失败: %w", err)
|
||||
}
|
||||
ulBody := readBody(ulResp)
|
||||
|
||||
var ul struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
IsFirstLogin bool `json:"isFirstLogin"`
|
||||
IsVerification bool `json:"is_Verification"`
|
||||
}
|
||||
if err := json.Unmarshal(ulBody, &ul); err != nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 无法解析登录响应")
|
||||
}
|
||||
if !ul.Success {
|
||||
msg := ul.Message
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查用户名和密码!"
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if ul.IsFirstLogin {
|
||||
return nil, nil, fmt.Errorf("该校要求首次登录在网页端完善个人信息后才能自动登录")
|
||||
}
|
||||
|
||||
// 5. 校验通过后提交登录表单换取 CAS 票据(密码同样为密文)。
|
||||
// 禁跳转以直接从 302 Location 拿到 ticket。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
formResp, err := sc.R().
|
||||
SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"execution": execution,
|
||||
"_eventId": eventId,
|
||||
"geolocation": geolocation,
|
||||
"key": randomString(32),
|
||||
"captcha": "",
|
||||
"submit": "登录",
|
||||
}).
|
||||
Post(finalURL.String())
|
||||
if err != nil {
|
||||
if formResp == nil || formResp.StatusCode() != 302 {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 提交登录表单失败: %w", err)
|
||||
}
|
||||
}
|
||||
if formResp == nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 登录表单无响应")
|
||||
}
|
||||
|
||||
switch formResp.StatusCode() {
|
||||
case 302:
|
||||
slog.Info("亳州学院 CAS 登录成功", "redirect", formResp.Header().Get("Location"))
|
||||
cookies, err := handleCasLoginSuccess(ctx, formResp, school, sc)
|
||||
return cookies, nil, err
|
||||
case 200, 401:
|
||||
doc, derr := goquery.NewDocumentFromReader(bytes.NewReader(readBody(formResp)))
|
||||
msg := ""
|
||||
if derr == nil {
|
||||
msg = getCasErrorMessage(doc)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查用户名和密码!"
|
||||
}
|
||||
// 学校端对频繁失败会下发 MORETHANLIMIT,此时即使账号密码正确也会报错
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(formResp.Header())), "morethanlimit") {
|
||||
msg += "(登录尝试过于频繁,已被学校限制,请稍后再试)"
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
rawBody := readBody(formResp)
|
||||
if d, derr := goquery.NewDocumentFromReader(bytes.NewReader(rawBody)); derr == nil {
|
||||
slog.Error("亳州学院表单提交异常", "code", formResp.StatusCode(), "text", truncateForLog(strings.Join(strings.Fields(d.Text()), " "), 600))
|
||||
} else {
|
||||
slog.Error("亳州学院表单提交异常", "code", formResp.StatusCode(), "body", truncateForLog(string(rawBody), 500))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录返回异常状态码: %d", formResp.StatusCode())
|
||||
}
|
||||
}
|
||||
@@ -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,7 +34,13 @@ 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)
|
||||
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
|
||||
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
|
||||
@@ -100,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滑块验证通过")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,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)
|
||||
@@ -211,6 +242,14 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底选择器 "form" 常命中多个表单(移动端账密、手机号、二维码等)。全部 input
|
||||
// 合并会把 phone/valiCode/key 等无关字段一起提交,学校端可能因此按手机号或验证码
|
||||
// 分支校验而拒绝账密登录(亳州学院实测 401)。此处优先锁定纯账密表单。
|
||||
if formSel.Length() > 1 {
|
||||
formSel = pickCredentialForm(formSel)
|
||||
slog.Info("CAS表单锁定账密表单", "count", formSel.Length())
|
||||
}
|
||||
|
||||
formSel.Find("input").Each(func(_ int, s *goquery.Selection) {
|
||||
name, _ := s.Attr("name")
|
||||
value, _ := s.Attr("value")
|
||||
@@ -251,7 +290,74 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// 检测是否需要验证码
|
||||
// pickCredentialForm 在多个表单中挑出最"纯"的账密表单。
|
||||
//
|
||||
// 评分:必须含密码框;不含手机号/短信码字段加分;不含 key/captcha(滑块、图片验证码
|
||||
// 专用隐藏域)再加分。页面同时存在移动端账密(带 key/captcha)与 PC 账密表单时,
|
||||
// 选后者——把空 key/captcha 一起提交会给学校端造成"要求验证码"的误判。
|
||||
func pickCredentialForm(forms *goquery.Selection) *goquery.Selection {
|
||||
var best *goquery.Selection
|
||||
bestScore := -1
|
||||
forms.Each(func(_ int, s *goquery.Selection) {
|
||||
hasPassword := s.Find("input[type=password]").Length() > 0 ||
|
||||
s.Find("input[name=password]").Length() > 0
|
||||
if !hasPassword {
|
||||
return
|
||||
}
|
||||
score := 10
|
||||
if s.Find("input[name=phone], input[name=valiCode], input[name=phoneCode]").Length() == 0 {
|
||||
score += 2
|
||||
}
|
||||
if s.Find("input[name=key]").Length() == 0 {
|
||||
score++
|
||||
}
|
||||
if s.Find("input[name=captcha]").Length() == 0 {
|
||||
score++
|
||||
}
|
||||
if score > bestScore {
|
||||
best, bestScore = s, score
|
||||
}
|
||||
})
|
||||
if best != nil {
|
||||
return best
|
||||
}
|
||||
return forms.First()
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -373,6 +479,9 @@ func getCasErrorMessage(doc *goquery.Document) string {
|
||||
selectors := []string{
|
||||
"#msg", "#loginError", ".login-error",
|
||||
".error", "#error", "p.error",
|
||||
// zs 主题(亳州学院等)把错误提示放在 .warn-text 里,如
|
||||
// <div class="warn-text"><span style="color:#ff0000">登录失败,请检查用户名和密码!</span>
|
||||
".warn-text", ".warn-text span", "#showErrorTip",
|
||||
}
|
||||
for _, sel := range selectors {
|
||||
msg := strings.TrimSpace(doc.Find(sel).Text())
|
||||
|
||||
@@ -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"
|
||||
@@ -2,6 +2,8 @@ package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -35,11 +37,14 @@ 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)
|
||||
loginUrl := hfnuLoginEntry(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
@@ -71,15 +76,47 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 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": password,
|
||||
"password": base64.StdEncoding.EncodeToString([]byte(password)),
|
||||
"validateCode": captchaCode,
|
||||
"lt": lt,
|
||||
"lt": hfnuLtKey,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
@@ -101,7 +138,9 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
msg := parseHfnuLoginError(readBody(loginResp))
|
||||
respBody := readBody(loginResp)
|
||||
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
|
||||
msg := parseHfnuLoginError(respBody)
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
@@ -174,10 +213,29 @@ func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, c
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
code := captchaOCR(imgData)
|
||||
if code == "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -191,3 +249,25 @@ func parseHfnuLoginError(body []byte) string {
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
|
||||
// hfnuLoginEntry 返回该校登录入口。
|
||||
//
|
||||
// 不能用通用的 getCasValidLoginUrl:平台给 hfnu 的配置是
|
||||
//
|
||||
// idsUrl = http://ehall.hfnu.edu.cn/amp-auth-adapter
|
||||
// appId = d7de360368c94a808225
|
||||
//
|
||||
// 而通用函数的判断只认 campusphere / wec-portal-mobile,
|
||||
// 遇到 amp-auth-adapter 会回落 WecloudUrl,导致入口选错。
|
||||
//
|
||||
// 该校真实链路是 ehall 的 amp-auth-adapter(金智),
|
||||
// 登录页由 /sso/login 承载,继续用 idsUrl 作为入口即可。
|
||||
func hfnuLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "amp-auth-adapter") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return getCasValidLoginUrl(school)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
@@ -19,27 +20,43 @@ import (
|
||||
// 今日校园定制 CAS 的多因子认证扩展(reAuthCheck)。
|
||||
// 触发场景:CAS 账密提交成功后不是直接签发 ST 票据,而是 302 到
|
||||
// {base}/authserver/reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=<encoded>
|
||||
// 该页面声明二次认证方式(reAuthType),其中动态码类型需先调用发码接口,
|
||||
// 用户取得验证码后再提交 reAuthSubmit.do 完成认证,最后访问 /login?service= 完成票据交换。
|
||||
// 该页面渲染当前用户全部可用的二次认证方式(Tab / 更多菜单 / 旧版列表),
|
||||
// 调用方应把方式列表返回给用户选择;用户选定后调用发码接口(按需先 changeReAuthType.do
|
||||
// 切换服务端会话方式),用户取得验证码后再提交 reAuthSubmit.do 完成认证,
|
||||
// 最后访问 /login?service= 完成票据交换。
|
||||
|
||||
// ErrMFARequired 表示本次登录触发了多因子认证,已自动发送验证码,
|
||||
// 调用方应将返回的 MFAContext 暂存并引导用户提交验证码。
|
||||
// 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"` // 学工号
|
||||
AuthCodeTypeName string `json:"authCodeTypeName"` // 发码接口的类型名
|
||||
WecloudUrl string `json:"wecloudUrl"` // 完成登录后 activateSession 所需
|
||||
CookiesByHost map[string][]*http.Cookie `json:"cookiesByHost"` // CAS 阶段会话(按域保存)
|
||||
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)
|
||||
@@ -53,6 +70,22 @@ var reAuthCodeTypeNames = map[string]string{
|
||||
"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]
|
||||
@@ -65,10 +98,107 @@ var (
|
||||
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 供后续提交。
|
||||
// 解析 MFA 页面参数与全部可用验证方式,返回 MFAContext 供用户选择方式、发送并提交验证码。
|
||||
func handleMFATriggered(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) (*MFAContext, error) {
|
||||
location, err := resp.RawResponse.Location()
|
||||
if err != nil {
|
||||
@@ -119,17 +249,15 @@ func handleMFATriggered(ctx context.Context, resp *resty.Response, school *Schoo
|
||||
mfa.ReAuthType = "5"
|
||||
slog.Warn("MFA页面未解析到reAuthType,按动态码处理", "base", mfa.BaseURL)
|
||||
}
|
||||
slog.Info("MFA参数解析", "reAuthType", mfa.ReAuthType, "userId", mfa.UserID, "service", mfa.Service)
|
||||
|
||||
// 动态码类型:自动发送验证码
|
||||
if reAuthTypeNeedsDynamicCode(mfa.ReAuthType) {
|
||||
mfa.AuthCodeTypeName = reAuthCodeTypeNames[mfa.ReAuthType]
|
||||
if err := sendReAuthDynamicCode(ctx, sc.Client, mfa); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 密码重认证(type 2)等类型暂不支持自动处理,需要前端提示
|
||||
slog.Warn("MFA类型暂不支持自动发送验证码", "reAuthType", mfa.ReAuthType)
|
||||
// 解析页面全部可用验证方式(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 会话(按域)
|
||||
@@ -138,44 +266,153 @@ func handleMFATriggered(ctx context.Context, resp *resty.Response, school *Schoo
|
||||
return mfa, nil
|
||||
}
|
||||
|
||||
// sendReAuthDynamicCode 调用 CAS 发码接口发送验证码
|
||||
// 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 sendReAuthDynamicCode(ctx context.Context, client *resty.Client, mfa *MFAContext) error {
|
||||
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": mfa.AuthCodeTypeName,
|
||||
"authCodeTypeName": authCodeTypeName,
|
||||
}).
|
||||
Post(sendUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送MFA验证码失败: %w", err)
|
||||
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 struct {
|
||||
Res string `json:"res"`
|
||||
ReturnMessage string `json:"returnMessage"`
|
||||
Mobile string `json:"mobile"`
|
||||
}
|
||||
var result sendReAuthDynamicCodeResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("发送MFA验证码响应解析失败: %w", err)
|
||||
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 nil
|
||||
return &result, nil
|
||||
default:
|
||||
msg := result.ReturnMessage
|
||||
if msg == "" {
|
||||
msg = "发送验证码失败,请稍后重试"
|
||||
}
|
||||
return fmt.Errorf("%s", 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) {
|
||||
@@ -188,13 +425,7 @@ func CompleteMFALogin(ctx context.Context, mfa *MFAContext, code string) ([]*htt
|
||||
|
||||
sc := newSessionClient()
|
||||
// 恢复 CAS 阶段会话
|
||||
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)
|
||||
}
|
||||
restoreMFACasSession(sc, mfa)
|
||||
|
||||
// 1. 提交验证码(reAuthSubmit.do 返回 JSON,不涉及重定向)
|
||||
submitUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/reAuthSubmit.do"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
|
||||
|
||||
client := newMobileClient()
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
Get(host + "/wec-im-group/group/groupMember/getCurrentUserId")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
|
||||
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
|
||||
func TestXsgzFetchRSAValidation(t *testing.T) {
|
||||
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
|
||||
goodDK := strings.Repeat("a", 32)
|
||||
goodM := "00" + strings.Repeat("b", 256)
|
||||
mk := func(dk, m, e string) string {
|
||||
var sb strings.Builder
|
||||
if dk != "" {
|
||||
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
|
||||
}
|
||||
if m != "" {
|
||||
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
|
||||
}
|
||||
if e != "" {
|
||||
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{"正常", mk(goodDK, goodM, "010001"), ""},
|
||||
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
|
||||
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
|
||||
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
|
||||
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
|
||||
{"空响应", "", "解析公钥参数失败"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := xsgzFetchRSA(resty.New(), srv.URL)
|
||||
if c.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("应成功,实际报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("应报错但未报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,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
|
||||
@@ -142,9 +180,26 @@ func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
|
||||
detail.NamePinYin = getPinYinFromID(tenantID)
|
||||
detail.NameAbbr = getAbbrFromName(detail.Name)
|
||||
|
||||
// 部分学校返回的域名不带协议(如 "jatc.campusphere.net"),
|
||||
// 直接拼接口路径会得到 "host/path",net/http 报 unsupported protocol scheme ""。
|
||||
detail.AmpUrl = ensureURLScheme(detail.AmpUrl)
|
||||
detail.AmpUrl2 = ensureURLScheme(detail.AmpUrl2)
|
||||
detail.AmpUrl3 = ensureURLScheme(detail.AmpUrl3)
|
||||
detail.IdsUrl = ensureURLScheme(detail.IdsUrl)
|
||||
detail.WecloudUrl = ensureURLScheme(detail.WecloudUrl)
|
||||
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
// ensureURLScheme 为缺省协议的地址补 https://;已有 scheme(含 http://)的原样返回。
|
||||
func ensureURLScheme(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || strings.Contains(u, "://") {
|
||||
return u
|
||||
}
|
||||
return "https://" + u
|
||||
}
|
||||
|
||||
// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP)
|
||||
var iapSchoolIds = map[string]bool{
|
||||
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
|
||||
@@ -165,12 +220,60 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
// 合肥师范学院:历史尝试走 IAP(网关 iap/doLogin 账密),但实测该校
|
||||
// hfnu.campusphere.net/iap/login 直接 302 到
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/login(ehall 单点),
|
||||
// 拿不到 IAP 登录表单,getIapLT 必然失败。
|
||||
// 而 ehall 侧登录只产出 ehall 域 cookie(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/
|
||||
// asessionid),换不到 campusphere 的 MOD_AUTH_CAS(见 10-19 抓包分析)。
|
||||
// 校方 CAS 亦拒绝为 campusphere 域签发 service 票据。
|
||||
// 结论:hfnu 目前应走手机号+短信登录(MobileLoginByCode → ExchangeCasCookie)。
|
||||
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)
|
||||
}
|
||||
// 亳州学院:平台配置 joinType=CLOUD(IsIap=true),但网关 /iap/login 实际 302 到
|
||||
// 学校 CAS(auth.bzuu.edu.cn/authserver),IAP 账密接口要求验证码不可用;
|
||||
// 真实链路为标准 CAS 表单(密码明文、当前无验证码),单独强制走 CAS(详见 login_bzuu.go)
|
||||
if school.ID == BzuuSchoolId {
|
||||
return bzuuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
|
||||
func realRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"taskName": "2026(1)节假日返校签到",
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"currentTime": "2026-10-19 11:31:21",
|
||||
"signStatus": "2",
|
||||
"taskType": float64(6),
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowReal(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("应能解析出时间段")
|
||||
}
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
t.Fatal("起止时间不应为零值")
|
||||
}
|
||||
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
|
||||
|
||||
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
|
||||
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
|
||||
if !w.Start.Equal(wantStart) {
|
||||
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
|
||||
}
|
||||
if !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSignWindowReal(t *testing.T) {
|
||||
rows := realRows()
|
||||
|
||||
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
|
||||
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
|
||||
err := CheckSignWindow(rows, now)
|
||||
if err == nil {
|
||||
t.Fatal("不在时间段内应返回错误")
|
||||
}
|
||||
if !errors.Is(err, ErrOutsideSignWindow) {
|
||||
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
|
||||
}
|
||||
t.Logf("拦截提示: %v", err)
|
||||
|
||||
// 时段内:应放行
|
||||
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
|
||||
if err := CheckSignWindow(rows, inWin); err != nil {
|
||||
t.Fatalf("时段内不应拦截: %v", err)
|
||||
}
|
||||
|
||||
// 边界:开始时刻与结束时刻本身应算在窗口内
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
|
||||
t.Fatalf("开始时刻应放行: %v", err)
|
||||
}
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
|
||||
t.Fatalf("结束时刻应放行: %v", err)
|
||||
}
|
||||
// 刚过结束一分钟:应拦截
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
|
||||
t.Fatal("超时后应拦截")
|
||||
}
|
||||
}
|
||||
|
||||
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// 时间信息缺失时不应拦截,避免误杀。
|
||||
func TestCheckSignWindowNoInfo(t *testing.T) {
|
||||
cases := []map[string]interface{}{
|
||||
nil,
|
||||
{},
|
||||
{"taskName": "x"},
|
||||
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
|
||||
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
|
||||
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
|
||||
}
|
||||
for i, rows := range cases {
|
||||
if err := CheckSignWindow(rows, time.Now()); err != nil {
|
||||
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// singleTask* 存在时优先使用(单次任务精确区间)。
|
||||
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"singleTaskBeginTime": "2026-10-19 08:00:00",
|
||||
"singleTaskEndTime": "2026-10-19 12:00:00",
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("应解析成功")
|
||||
}
|
||||
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
|
||||
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
|
||||
}
|
||||
// 在该区间内应放行
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
|
||||
t.Fatalf("单次任务区间内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
|
||||
func sleepCheckRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"rateSignDate": "2026-10-19 (周一)",
|
||||
"rateTaskBeginTime": "22:30",
|
||||
"rateTaskEndTime": "23:30",
|
||||
"singleTaskBeginTime": "2026-10-19 22:30",
|
||||
"singleTaskEndTime": "2026-10-19 23:30",
|
||||
"iotEndTime": "23:30",
|
||||
"supplyTime": "",
|
||||
"signStatus": "0",
|
||||
"currentTime": "2026-10-19 11:37:32",
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowSleepCheck(t *testing.T) {
|
||||
w := ParseSignWindow(sleepCheckRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("查寝应能解析出时间段")
|
||||
}
|
||||
wantStart := wantTime(2026, 10, 19, 22, 30)
|
||||
wantEnd := wantTime(2026, 10, 19, 23, 30)
|
||||
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
|
||||
}
|
||||
// 当日上午 → 应拦截
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
|
||||
t.Fatal("上午应拦截")
|
||||
}
|
||||
// 时段内 → 放行
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
|
||||
t.Fatalf("时段内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
|
||||
func TestParseSignWindowReturnRegister(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"signStatus": "2",
|
||||
"singleTaskBeginTime": nil,
|
||||
"singleTaskEndTime": nil,
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("返校签到应能解析")
|
||||
}
|
||||
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
|
||||
t.Fatalf("解析错: %s", w.Start)
|
||||
}
|
||||
}
|
||||
|
||||
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
|
||||
func TestSignUpdateBlocked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows map[string]interface{}
|
||||
blocked bool
|
||||
}{
|
||||
{
|
||||
name: "未签到(signStatus=2) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到且禁止更新 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "已签到但允许更新 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
|
||||
rows: map[string]interface{}{"signStatus": float64(1)},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、有次数限制但还剩 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、次数用尽 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "请假(signStatus=5) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "5"},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "nil rows → 不拦截",
|
||||
rows: nil,
|
||||
blocked: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, blocked := SignUpdateBlocked(c.rows)
|
||||
if blocked != c.blocked {
|
||||
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
|
||||
}
|
||||
if blocked && msg == "" {
|
||||
t.Fatal("拦截时应给出提示文案")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
|
||||
func TestEvalTaskPhase(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("应为 noStart,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
|
||||
t.Fatalf("应为 running,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
|
||||
t.Fatalf("应为 finished,实际 %q", got)
|
||||
}
|
||||
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// HquSchoolId 华侨大学 tenant id(CAS 登录需 wisedu 滑块验证码,IAP 账密接口不兼容)
|
||||
const HquSchoolId = "hqdx"
|
||||
|
||||
// isSliderCaptchaSchool 判断学校是否为滑块验证码学校(wisedu 定制 CAS 滑块)。
|
||||
// 学校列表来自配置 [cpdaily] sliderIds(与老版一致:1018764144934965/sdhyxy/hqdx)。
|
||||
func isSliderCaptchaSchool(schoolID string) bool {
|
||||
for _, id := range config.Global.Cpdaily.SliderIds {
|
||||
if id == schoolID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ========== 滑块验证码识别(通用方案) ==========
|
||||
//
|
||||
// 与图片验证码 OCR(captchaOCR)对等的通用识别入口:sliderGapOCR。
|
||||
// 打码平台 /slider 接口(与 /classifier 等共用同一个 X-Api-Key):
|
||||
//
|
||||
// POST {captchaBaseUrl}/slider JSON: {"big_image": "<b64>", "small_image": "<b64>"}
|
||||
// 响应: {"code":200,"msg":"success","data":152}
|
||||
// data = 滑块需横向移动的像素距离(wisedu 口径,与服务端 verifySliderCaptcha 期望值同尺度)
|
||||
|
||||
// sliderGapOCR 识别滑块缺口横向位置(通用入口)。
|
||||
// bigImageB64/smallImageB64 为背景大图与滑块小图的 base64(不带 data: 前缀)。
|
||||
// 返回滑块需横向移动的像素距离。
|
||||
func sliderGapOCR(bigImageB64, smallImageB64 string) (int, error) {
|
||||
cfg := config.Global.Cpdaily
|
||||
resp, err := resty.New().
|
||||
SetTimeout(15*time.Second).
|
||||
R().
|
||||
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(map[string]string{
|
||||
"big_image": bigImageB64,
|
||||
"small_image": smallImageB64,
|
||||
}).
|
||||
Post(strings.TrimSuffix(cfg.CaptchaBaseUrl, "/") + "/slider")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("滑块识别请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data int `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
bodyStr := resp.String()
|
||||
if err := json.Unmarshal([]byte(bodyStr), &result); err != nil {
|
||||
return 0, fmt.Errorf("滑块识别响应解析失败: %w, body: %s", err, bodyStr)
|
||||
}
|
||||
if result.Code != 200 {
|
||||
return 0, fmt.Errorf("滑块识别失败: code=%d msg=%s", result.Code, result.Msg)
|
||||
}
|
||||
if result.Data <= 0 {
|
||||
return 0, fmt.Errorf("滑块识别结果无效: %d", result.Data)
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ========== wisedu CAS 滑块验证码(如华侨大学 id.hqu.edu.cn) ==========
|
||||
//
|
||||
// 前端协议(longbow sliderCaptcha v2.0 + encrypt.js,2026-02 逆向并实测调通):
|
||||
//
|
||||
// 1. GET {cas}/common/openSliderCaptcha.htl
|
||||
// → {"bigImage":"<b64>","smallImage":"<b64>","tagWidth":..,"yHeight":..}
|
||||
// 2. safeSecure = window.atob(smallImage) 末 16 字节(latin1→UTF-8 字符串,trim 后作 AES key)
|
||||
// 3. 拖动结束后 POST {cas}/common/verifySliderCaptcha.htl,表单仅一个字段 sign:
|
||||
// sign = encryptPassword(JSON.stringify({canvasLength,moveLength,tracks}), safeSecure)
|
||||
// encryptPassword(data,key) = AES-128-CBC-PKCS7(key=Utf8(key.trim()),
|
||||
// iv=randomString(16),明文=randomString(64)+data),输出标准 base64
|
||||
// 4. 响应 {"errorCode":1,"errorMsg":"success"} = 通过;{"errorCode":0} = 失败
|
||||
//
|
||||
// 服务端校验特性(实测修正,推翻此前「无容差 + ±12 扫描」结论):
|
||||
// - open 后存在约 1.3~1.7s 的激活延迟:延迟内提交任何值(含真值)都返回失败;
|
||||
// - 激活后服务端接受以真值为中心的宽区间(实测上缘 +8、下缘 -6~-8,相对打码值),
|
||||
// 打码平台返回值即为区间中心附近(误差 0±1),故等待激活后提交打码值本身即可通过;
|
||||
// - 失败不消费图片,可在同一张图上反复提交;仅验证成功才作废该图;
|
||||
// - 早期观察到的「打码偏差 -8~+9」为快速扫描跨过激活窗口的落点伪影(非真实识别误差)。
|
||||
|
||||
// wiseduSliderVerifyWidth 前端滑块容器宽度(canvasLength 固定上报值)
|
||||
const wiseduSliderVerifyWidth = 280
|
||||
|
||||
// wiseduAesChars encrypt.js 随机字符集(用于 64 位随机前缀与 16 位随机 IV)
|
||||
const wiseduAesChars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
|
||||
|
||||
// wiseduSliderMaxRounds 换图重试上限(每张图内先提交窄候选序列)
|
||||
const wiseduSliderMaxRounds = 5
|
||||
|
||||
// wiseduSliderActivateWait 服务端激活延迟等待时长:从 openSliderCaptcha 响应完成起算。
|
||||
// 实测激活点约在 open 后 1.3~1.7s(期间提交任何值都被拒),2.5s 留足抖动裕度。
|
||||
// 测试可替换为零。
|
||||
var wiseduSliderActivateWait = 2500 * time.Millisecond
|
||||
|
||||
// wiseduSliderSubmitGap 同图候选值之间的提交间隔,进一步吸收激活窗口抖动。
|
||||
// 测试可替换为零。
|
||||
var wiseduSliderSubmitGap = 350 * time.Millisecond
|
||||
|
||||
// wiseduSliderCandidates 同图候选值偏移序列:以打码值本身为中心(贴合接受区间中心),
|
||||
// ±1 仅作识别误差/激活抖动的兜底(激活后接受 ±8 宽区间,通常首个候选即命中)。
|
||||
var wiseduSliderCandidates = []int{0, 1, -1}
|
||||
|
||||
// wiseduSliderRetryDelay 换图重试等待时长(测试可替换为零延迟)
|
||||
var wiseduSliderRetryDelay = func(round int) time.Duration {
|
||||
return time.Duration(500+round*300) * time.Millisecond
|
||||
}
|
||||
|
||||
// wiseduSliderTrack 前端 tracks 元素(a=横向位移、b=纵向偏移、c=累计时间ms)
|
||||
type wiseduSliderTrack struct {
|
||||
A int `json:"a"`
|
||||
B int `json:"b"`
|
||||
C int `json:"c"`
|
||||
}
|
||||
|
||||
// wiseduSliderTrackPayload verifySliderCaptcha 加密前的 JSON 载荷
|
||||
type wiseduSliderTrackPayload struct {
|
||||
CanvasLength int `json:"canvasLength"`
|
||||
MoveLength int `json:"moveLength"`
|
||||
Tracks []wiseduSliderTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// resolveWiseduSliderCaptcha 完成 wisedu CAS 滑块验证(自动换图重试)。
|
||||
// casBase 为 CAS 根路径(如 https://id.hqu.edu.cn/authserver);
|
||||
// referer 为当前登录页 URL(模拟浏览器请求头);
|
||||
// client 须与登录页共用同一会话(cookie jar)。
|
||||
func resolveWiseduSliderCaptcha(ctx context.Context, casBase, referer string, client *resty.Client) error {
|
||||
return resolveWiseduSliderCaptchaFn(ctx, casBase, referer, client, sliderGapOCR)
|
||||
}
|
||||
|
||||
// resolveWiseduSliderCaptchaFn 允许注入缺口识别函数(测试用)
|
||||
func resolveWiseduSliderCaptchaFn(ctx context.Context, casBase, referer string, client *resty.Client,
|
||||
gapFn func(bigImageB64, smallImageB64 string) (int, error)) error {
|
||||
base := strings.TrimSuffix(casBase, "/")
|
||||
var lastErr error
|
||||
for round := 0; round < wiseduSliderMaxRounds; round++ {
|
||||
if round > 0 {
|
||||
time.Sleep(wiseduSliderRetryDelay(round))
|
||||
}
|
||||
lastErr = wiseduSliderOnce(ctx, base, referer, client, gapFn, round)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
slog.Info("wisedu滑块本轮失败,换图重试", "round", round+1, "err", lastErr.Error())
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// wiseduSliderOnce 单轮滑块验证:取图 → 打码 → 等激活窗口 → 提交窄候选序列(v、v+1、v-1)
|
||||
func wiseduSliderOnce(ctx context.Context, base, referer string, client *resty.Client,
|
||||
gapFn func(string, string) (int, error), round int) error {
|
||||
// 1. 获取滑块图片(同会话换图)
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", referer).
|
||||
Get(base + "/common/openSliderCaptcha.htl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取滑块图片失败: %w", err)
|
||||
}
|
||||
openedAt := time.Now()
|
||||
var slider struct {
|
||||
BigImage string `json:"bigImage"`
|
||||
SmallImage string `json:"smallImage"`
|
||||
}
|
||||
body := readBody(resp)
|
||||
if err := json.Unmarshal(body, &slider); err != nil || slider.BigImage == "" || slider.SmallImage == "" {
|
||||
return fmt.Errorf("解析滑块图片响应失败: %v, body: %s", err, string(body[:min(len(body), 200)]))
|
||||
}
|
||||
|
||||
// 2. safeSecure(小图末 16 字节)与打码识别
|
||||
safeSecure, err := sliderSafeSecure(slider.SmallImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gapX, err := gapFn(slider.BigImage, slider.SmallImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 等待服务端激活窗口:激活前提交任何值(含真值)都会被拒
|
||||
if wait := wiseduSliderActivateWait - time.Since(openedAt); wait > 0 {
|
||||
slog.Info("wisedu滑块等待激活窗口", "round", round+1, "wait", wait.String())
|
||||
time.Sleep(wait)
|
||||
}
|
||||
|
||||
// 4. 提交窄候选序列:打码值本身(接受区间中心),v±1 兜底激活抖动/识别误差
|
||||
for i, off := range wiseduSliderCandidates {
|
||||
if i > 0 && wiseduSliderSubmitGap > 0 {
|
||||
time.Sleep(wiseduSliderSubmitGap)
|
||||
}
|
||||
moveLength := gapX + off
|
||||
if moveLength <= 0 {
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(wiseduSliderTrackPayload{
|
||||
CanvasLength: wiseduSliderVerifyWidth,
|
||||
MoveLength: moveLength,
|
||||
Tracks: wiseduSliderTracks(moveLength),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sign, err := encryptSliderSign(string(payload), safeSecure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vResp, err := client.R().SetContext(ctx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", referer).
|
||||
SetFormData(map[string]string{"sign": sign}).
|
||||
Post(base + "/common/verifySliderCaptcha.htl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交滑块校验失败: %w", err)
|
||||
}
|
||||
var vResult struct {
|
||||
ErrorCode int `json:"errorCode"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
vBody := readBody(vResp)
|
||||
_ = json.Unmarshal(vBody, &vResult)
|
||||
slog.Info("wisedu滑块校验", "round", round+1, "gapX", gapX, "offset", off,
|
||||
"moveLength", moveLength, "errorCode", vResult.ErrorCode)
|
||||
if vResult.ErrorCode == 1 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("本张图候选值全部未通过(打码值 %d)", gapX)
|
||||
}
|
||||
|
||||
// sliderSafeSecure 取小图 base64 解码后的末 16 字节作为 safeSecure。
|
||||
// 前端 ids-sliderCaptcha.js 中 safeSecure.value 由 atob(smallImage) 的末 16 个
|
||||
// charCode 逐字节拼成;encrypt.js 使用时先 trim 再作 AES key(Utf8 编码)。
|
||||
func sliderSafeSecure(smallImageB64 string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(smallImageB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("小图 base64 解码失败: %w", err)
|
||||
}
|
||||
if len(raw) < 16 {
|
||||
return "", fmt.Errorf("小图数据不足 16 字节: %d", len(raw))
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range raw[len(raw)-16:] {
|
||||
sb.WriteRune(rune(c))
|
||||
}
|
||||
// 复刻 JS 正则 /(^\s+)|(\s+$)/g 的 trim(字节码点 ≤255,空白字符集有限)
|
||||
return strings.Trim(sb.String(), "\t\n\v\f\r \u00a0"), nil
|
||||
}
|
||||
|
||||
// encryptSliderSign 复刻 encrypt.js encryptPassword(data, key):
|
||||
// AES-128-CBC-PKCS7,key=Utf8(key.trim()),iv=randomString(16),
|
||||
// 明文=randomString(64)+data,输出标准 base64(仅密文,IV 不外传,与 CryptoJS 一致)。
|
||||
//
|
||||
// IV 随机且不随请求发送是安全的:CBC 解密时 IV 只影响第一块(16 字节)明文,
|
||||
// 而首块落在 64 位随机前缀内部;服务端剥离前缀后从固定偏移解析 JSON,不受影响
|
||||
// (已用真实 CryptoJS 输出 + openssl/node 解密交叉验证)。
|
||||
func encryptSliderSign(data, safeSecure string) (string, error) {
|
||||
key := []byte(safeSecure)
|
||||
switch len(key) {
|
||||
case 16, 24, 32:
|
||||
default:
|
||||
return "", fmt.Errorf("safeSecure 长度非法(%d 字节,须为 16/24/32)", len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
padded := pkcs7Pad([]byte(randomAesChars(64)+data), block.BlockSize())
|
||||
iv := []byte(randomAesChars(aes.BlockSize))
|
||||
ciphertext := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// wiseduSliderTracks 生成拟人化拖动轨迹,模拟 longbow.js 采集的 {a,b,c} 采样点:
|
||||
// 首个点为 {0,0,0},随后按比例推进并带轻微抖动,最后一点精确对齐 moveLength
|
||||
// (对应前端 mouseup 时的最终位移)。
|
||||
func wiseduSliderTracks(move int) []wiseduSliderTrack {
|
||||
steps := 8 + randSmallN(5) // 8~12 个中间采样点
|
||||
tracks := make([]wiseduSliderTrack, 0, steps+2)
|
||||
tracks = append(tracks, wiseduSliderTrack{A: 0, B: 0, C: 0})
|
||||
a, c := 0, 0
|
||||
for i := 1; i <= steps; i++ {
|
||||
target := move * i / steps
|
||||
if target <= a {
|
||||
target = a + 1
|
||||
}
|
||||
if target > move {
|
||||
target = move
|
||||
}
|
||||
a = target + randSmallN(3) - 1 // ±1 抖动
|
||||
if a > move {
|
||||
a = move
|
||||
}
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
c += 20 + randSmallN(45)
|
||||
tracks = append(tracks, wiseduSliderTrack{A: a, B: randSmallN(3) - 1, C: c})
|
||||
}
|
||||
c += 30 + randSmallN(40)
|
||||
tracks = append(tracks, wiseduSliderTrack{A: move, B: 0, C: c})
|
||||
return tracks
|
||||
}
|
||||
|
||||
// randomAesChars 生成 n 个 wiseduAesChars 字符集的随机字符(对应 encrypt.js randomString)
|
||||
func randomAesChars(n int) string {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
for i, c := range buf {
|
||||
buf[i] = wiseduAesChars[int(c)%len(wiseduAesChars)]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// randSmallN 返回 [0,n) 的伪随机数(仅用于轨迹/时间的拟人化抖动,无安全要求)
|
||||
func randSmallN(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
b := make([]byte, 1)
|
||||
_, _ = rand.Read(b)
|
||||
return int(b[0]) % n
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== isSliderCaptchaSchool ==========
|
||||
|
||||
func TestIsSliderCaptchaSchool(t *testing.T) {
|
||||
old := config.Global.Cpdaily.SliderIds
|
||||
defer func() { config.Global.Cpdaily.SliderIds = old }()
|
||||
config.Global.Cpdaily.SliderIds = []string{"1018764144934965", "sdhyxy", "hqdx"}
|
||||
|
||||
for _, id := range []string{"hqdx", "sdhyxy", "1018764144934965"} {
|
||||
if !isSliderCaptchaSchool(id) {
|
||||
t.Errorf("%s 应为滑块学校", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"xcu", "nut", "hfnu", ""} {
|
||||
if isSliderCaptchaSchool(id) {
|
||||
t.Errorf("%s 不应为滑块学校", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== safeSecure / sign 加密(encrypt.js 协议) ==========
|
||||
|
||||
// mockSliderSafe 模拟小图末 16 字节(hqu 实测为 ASCII 串)
|
||||
const mockSliderSafe = "Ta1PFklnamXPYqZX"
|
||||
|
||||
// mockSmallImageB64 构造以 mockSliderSafe 结尾的 base64 小图
|
||||
func mockSmallImageB64() string {
|
||||
raw := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01}, []byte(mockSliderSafe)...)
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func TestSliderSafeSecure(t *testing.T) {
|
||||
got, err := sliderSafeSecure(mockSmallImageB64())
|
||||
if err != nil {
|
||||
t.Fatalf("sliderSafeSecure: %v", err)
|
||||
}
|
||||
if got != mockSliderSafe {
|
||||
t.Fatalf("safeSecure=%q, want %q", got, mockSliderSafe)
|
||||
}
|
||||
|
||||
// 首尾空白应被 trim(复刻 encrypt.js 的 key.replace 正则)
|
||||
trimmed := base64.StdEncoding.EncodeToString([]byte("\tTa1PFklnamXPYqZ\n"))
|
||||
if got, err := sliderSafeSecure(trimmed); err != nil || got != "Ta1PFklnamXPYqZ" {
|
||||
t.Fatalf("trim 后 safeSecure=%q err=%v", got, err)
|
||||
}
|
||||
|
||||
// 不足 16 字节应报错
|
||||
short := base64.StdEncoding.EncodeToString([]byte("short"))
|
||||
if _, err := sliderSafeSecure(short); err == nil {
|
||||
t.Fatal("小图不足 16 字节应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptSliderSign(t *testing.T) {
|
||||
const key = "0123456789abcdef"
|
||||
plain := `{"canvasLength":280,"moveLength":99,"tracks":[{"a":0,"b":0,"c":0}]}`
|
||||
|
||||
sign, err := encryptSliderSign(plain, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptSliderSign: %v", err)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(sign)
|
||||
if err != nil || len(raw)%aes.BlockSize != 0 {
|
||||
t.Fatalf("sign base64/长度异常: err=%v len=%d", err, len(raw))
|
||||
}
|
||||
|
||||
// 用零 IV 解密(IV 只影响首块 16 字节,落于前缀内,不影响前缀后半段与载荷)
|
||||
block, _ := aes.NewCipher([]byte(key))
|
||||
pt := make([]byte, len(raw))
|
||||
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(pt, raw)
|
||||
pt = pkcs7Unpad(pt)
|
||||
|
||||
if len(pt) != 64+len(plain) {
|
||||
t.Fatalf("解密明文长度=%d, want %d", len(pt), 64+len(plain))
|
||||
}
|
||||
if string(pt[64:]) != plain {
|
||||
t.Fatalf("解密明文=%q, want %q", string(pt[64:]), plain)
|
||||
}
|
||||
// 首块因 IV 未知为乱码,第 16~63 字节应为合法前缀字符(48 个)
|
||||
for _, c := range string(pt[16:64]) {
|
||||
if !strings.ContainsRune(wiseduAesChars, c) {
|
||||
t.Fatalf("随机前缀包含非法字符 %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 真实 CryptoJS(/tmp/hqu_encrypt.js encryptPassword)产出的 sign 夹具:
|
||||
// key=Ta1PFklnamXPYqZX,明文载荷为 canvasLength=280/moveLength=65/2 个 tracks 采样点。
|
||||
// 用于锁定与前端 CryptoJS 的互通性(前缀长度、填充、base64、载荷可解析)。
|
||||
const realCryptoJSSignFixture = "+vjAgkLevuKAVqM/x6dCJJwlR5xLcrJ0yW6tqTmeu8MXCCBJvY4rttiA4ouUBHYdCP2zjYzbGgKDyTvTQdmvPnOV6DlSiQvCIhtaQcBksVFjYsMi2i7hCaKu3sfA9SszYZVs/8AvSqPPcvr+MlvarV7fSPaJdtPpbI1lYSU30MiFHBTl5V8cZMdlA+Zj290xoQlg3niG2V3f0sLzEQ4Wrg=="
|
||||
|
||||
func TestDecryptRealCryptoJSFixture(t *testing.T) {
|
||||
pt, err := decryptMockSign(realCryptoJSSignFixture)
|
||||
if err != nil {
|
||||
t.Fatalf("解密真实 CryptoJS sign 失败: %v", err)
|
||||
}
|
||||
p := mockDecryptSign(t, realCryptoJSSignFixture)
|
||||
if p.CanvasLength != 280 || p.MoveLength != 65 {
|
||||
t.Fatalf("载荷字段错误: %+v", p)
|
||||
}
|
||||
if len(p.Tracks) != 2 || p.Tracks[1].A != 65 {
|
||||
t.Fatalf("tracks 错误: %+v", p.Tracks)
|
||||
}
|
||||
// 第 16~63 字节为合法前缀字符(48 个)
|
||||
for _, c := range string(pt[16:64]) {
|
||||
if !strings.ContainsRune(wiseduAesChars, c) {
|
||||
t.Fatalf("随机前缀包含非法字符 %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWiseduSliderTracks(t *testing.T) {
|
||||
tracks := wiseduSliderTracks(65)
|
||||
if len(tracks) < 2 {
|
||||
t.Fatalf("tracks 采样点过少: %d", len(tracks))
|
||||
}
|
||||
if first := tracks[0]; first.A != 0 || first.B != 0 || first.C != 0 {
|
||||
t.Fatalf("首个采样点应为 {0,0,0}: %+v", first)
|
||||
}
|
||||
if last := tracks[len(tracks)-1]; last.A != 65 {
|
||||
t.Fatalf("末点 a=%d, want 65(对齐 moveLength)", last.A)
|
||||
}
|
||||
prevC := 0
|
||||
for i, p := range tracks {
|
||||
if p.C < prevC {
|
||||
t.Fatalf("第 %d 点时间回退: %+v", i, p)
|
||||
}
|
||||
if p.A < 0 || p.A > 65 {
|
||||
t.Fatalf("第 %d 点位移越界: %+v", i, p)
|
||||
}
|
||||
prevC = p.C
|
||||
}
|
||||
}
|
||||
|
||||
// ========== wisedu CAS 滑块流程(httptest mock,sign 协议) ==========
|
||||
|
||||
// disableSliderDelays 将换图重试延迟、激活等待与候选间隔全部置零(加速测试)
|
||||
func disableSliderDelays(t *testing.T) {
|
||||
t.Helper()
|
||||
oldDelay := wiseduSliderRetryDelay
|
||||
oldWait := wiseduSliderActivateWait
|
||||
oldGap := wiseduSliderSubmitGap
|
||||
wiseduSliderRetryDelay = func(int) time.Duration { return 0 }
|
||||
wiseduSliderActivateWait = 0
|
||||
wiseduSliderSubmitGap = 0
|
||||
t.Cleanup(func() {
|
||||
wiseduSliderRetryDelay = oldDelay
|
||||
wiseduSliderActivateWait = oldWait
|
||||
wiseduSliderSubmitGap = oldGap
|
||||
})
|
||||
}
|
||||
|
||||
// mockDecryptSign 用 mockSliderSafe 解密并解析 sign 载荷(模拟服务端行为)。
|
||||
// 加密端 IV 随机且不外传:CBC 中 IV 只影响解密后第一块(16 字节),而该 16 字节
|
||||
// 落在 64 位随机前缀内部,服务端剥掉前缀后解析 JSON 不受任何影响,故此处 IV 取
|
||||
// 零向量即可,解密结果第 16 字节起与前端一致(已用真实 CryptoJS 输出交叉验证)。
|
||||
func mockDecryptSign(t *testing.T, sign string) wiseduSliderTrackPayload {
|
||||
t.Helper()
|
||||
pt, err := decryptMockSign(sign)
|
||||
if err != nil {
|
||||
t.Fatalf("解密 sign 失败: %v", err)
|
||||
}
|
||||
var p wiseduSliderTrackPayload
|
||||
if err := json.Unmarshal(pt[64:], &p); err != nil {
|
||||
t.Fatalf("解密载荷 JSON 解析失败: %v, prefix64=%q body=%s", err, string(pt[:64]), string(pt[64:]))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decryptMockSign 用 mockSliderSafe 解密 sign(零 IV),返回去除 PKCS7 填充后的明文
|
||||
func decryptMockSign(sign string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(sign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) < 32 || len(raw)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("sign 长度异常: %d", len(raw))
|
||||
}
|
||||
block, err := aes.NewCipher([]byte(mockSliderSafe))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt := make([]byte, len(raw))
|
||||
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(pt, raw)
|
||||
pt = pkcs7Unpad(pt)
|
||||
if len(pt) < 64 {
|
||||
return nil, fmt.Errorf("解密后明文过短: %d", len(pt))
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// mockSliderServer 起一个 wisedu CAS 滑块 mock:
|
||||
// verify 接受 accept 集合中的 moveLength(errorCode=1),其余一律 errorCode=0
|
||||
func mockSliderServer(t *testing.T, accept map[int]bool, wantGapX int,
|
||||
openCalls, verifyCalls *int) *httptest.Server {
|
||||
t.Helper()
|
||||
small := mockSmallImageB64()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
*openCalls++
|
||||
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
|
||||
t.Errorf("open 缺少 X-Requested-With 头")
|
||||
}
|
||||
if r.Header.Get("Referer") == "" {
|
||||
t.Errorf("open 缺少 Referer 头")
|
||||
}
|
||||
writeJSON(w, map[string]string{"bigImage": "ZmFrZWJpZw==", "smallImage": small})
|
||||
})
|
||||
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
*verifyCalls++
|
||||
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
|
||||
t.Errorf("verify 缺少 X-Requested-With 头")
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
p := mockDecryptSign(t, r.PostFormValue("sign"))
|
||||
if p.CanvasLength != 280 {
|
||||
t.Errorf("canvasLength=%d, want 280", p.CanvasLength)
|
||||
}
|
||||
if len(p.Tracks) < 2 {
|
||||
t.Errorf("tracks 采样点过少: %d", len(p.Tracks))
|
||||
}
|
||||
if last := p.Tracks[len(p.Tracks)-1]; last.A != p.MoveLength {
|
||||
t.Errorf("tracks 末点 a=%d 应等于 moveLength=%d", last.A, p.MoveLength)
|
||||
}
|
||||
if accept[p.MoveLength] {
|
||||
writeJSON(w, map[string]int{"errorCode": 1})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]int{"errorCode": 0})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
// 打码值直接命中:1 次 open + 1 次 verify
|
||||
func TestResolveWiseduSliderCaptchaMock(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{65: true}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
if openCalls != 1 || verifyCalls != 1 {
|
||||
t.Fatalf("open=%d verify=%d, want 1/1", openCalls, verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 打码值略偏(真值=打码值+1):同一张图第 2 个候选即命中,不应换图
|
||||
func TestResolveWiseduSliderCaptchaCandidateHit(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{66: true}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
// 失败不换图(±1 由同图候选覆盖),成功即止
|
||||
if openCalls != 1 {
|
||||
t.Fatalf("open=%d, want 1(同图候选不应换图)", openCalls)
|
||||
}
|
||||
if verifyCalls != 2 {
|
||||
t.Fatalf("verify=%d, want 2(65 失败后 66 命中)", verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 真值=打码值-1:第 3 个候选(65,66,64 中 64)命中
|
||||
func TestResolveWiseduSliderCaptchaCandidateHitBelow(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{64: true}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
if openCalls != 1 || verifyCalls != 3 {
|
||||
t.Fatalf("open=%d verify=%d, want 1/3(65,66 失败后 64 命中)", openCalls, verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 5 张图全失败:每图 3 个候选值(0,+1,-1),共 15 次 verify 后报错
|
||||
func TestResolveWiseduSliderCaptchaExhausted(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err == nil {
|
||||
t.Fatal("持续失败应返回错误")
|
||||
}
|
||||
if openCalls != 5 {
|
||||
t.Fatalf("open=%d, want 5", openCalls)
|
||||
}
|
||||
if verifyCalls != 3*5 {
|
||||
t.Fatalf("verify=%d, want %d", verifyCalls, 3*5)
|
||||
}
|
||||
}
|
||||
|
||||
// 取图失败(非 JSON 响应)应报错并换图重试
|
||||
func TestResolveWiseduSliderCaptchaBadResponse(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
openCalls++
|
||||
_, _ = w.Write([]byte("<html>error</html>"))
|
||||
})
|
||||
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
verifyCalls++
|
||||
t.Error("取图失败时不应调用 verify")
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil }); err == nil {
|
||||
t.Fatal("应返回错误")
|
||||
}
|
||||
if openCalls != 5 {
|
||||
t.Fatalf("open=%d, want 5", openCalls)
|
||||
}
|
||||
if verifyCalls != 0 {
|
||||
t.Fatalf("verify=%d, want 0", verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端激活延迟:mock 在 open 后 300ms 内拒绝一切值(复刻实测 1.3~1.7s 激活窗口),
|
||||
// 等待逻辑(此处缩短为 400ms)应保证首个候选即命中:open=1、verify=1。
|
||||
// 若等待缺失,首个候选会落在拒绝窗口内而多提交一轮,verify 数与 open 数都会变化。
|
||||
func TestResolveWiseduSliderCaptchaActivationWait(t *testing.T) {
|
||||
oldWait := wiseduSliderActivateWait
|
||||
oldGap := wiseduSliderSubmitGap
|
||||
oldDelay := wiseduSliderRetryDelay
|
||||
wiseduSliderActivateWait = 400 * time.Millisecond
|
||||
wiseduSliderSubmitGap = 0
|
||||
wiseduSliderRetryDelay = func(int) time.Duration { return 0 }
|
||||
t.Cleanup(func() {
|
||||
wiseduSliderActivateWait = oldWait
|
||||
wiseduSliderSubmitGap = oldGap
|
||||
wiseduSliderRetryDelay = oldDelay
|
||||
})
|
||||
|
||||
const rejectWithin = 300 * time.Millisecond
|
||||
var openCalls, verifyCalls int
|
||||
var openAt time.Time
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
openCalls++
|
||||
openAt = time.Now()
|
||||
writeJSON(w, map[string]string{"bigImage": "ZmFrZWJpZw==", "smallImage": mockSmallImageB64()})
|
||||
})
|
||||
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
verifyCalls++
|
||||
_ = r.ParseForm()
|
||||
p := mockDecryptSign(t, r.PostFormValue("sign"))
|
||||
if time.Since(openAt) >= rejectWithin && p.MoveLength == 65 {
|
||||
writeJSON(w, map[string]int{"errorCode": 1})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]int{"errorCode": 0})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
if openCalls != 1 || verifyCalls != 1 {
|
||||
t.Fatalf("open=%d verify=%d, want 1/1(激活等待后首个候选应命中)", openCalls, verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== parseLoginForm 表单选择(#pwdLoginDiv 优先级) ==========
|
||||
|
||||
// 模拟华侨大学登录页:3 个 form(fido/动态码/账密),账密字段位于 #pwdLoginDiv
|
||||
func TestParseLoginFormPwdLoginDiv(t *testing.T) {
|
||||
inputs := parseLoginForm([]byte(hquLoginPageHTML), "2523132014", "testpwd123")
|
||||
|
||||
if inputs["cllt"] != "userNameLogin" {
|
||||
t.Fatalf("应选中 #pwdLoginDiv 账密表单, cllt=%s", inputs["cllt"])
|
||||
}
|
||||
if inputs["username"] != "2523132014" {
|
||||
t.Fatalf("username 错误: %s", inputs["username"])
|
||||
}
|
||||
if inputs["execution"] != "e1s1" {
|
||||
t.Fatalf("execution 错误: %s", inputs["execution"])
|
||||
}
|
||||
// 密码应被 AES 加密(含 pwdEncryptSalt)
|
||||
if inputs["password"] == "" || inputs["password"] == "testpwd123" {
|
||||
t.Fatalf("password 应被加密: %q", inputs["password"])
|
||||
}
|
||||
// userPassword(明文框)应被删除
|
||||
if _, ok := inputs["userPassword"]; ok {
|
||||
t.Fatal("userPassword 字段应被删除")
|
||||
}
|
||||
// captcha 留空(滑块通过后提交)
|
||||
if v, ok := inputs["captcha"]; !ok || v != "" {
|
||||
t.Fatalf("captcha 应保留空值: %q %v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助 ==========
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func newTestRestyClient() *resty.Client {
|
||||
// 与 sessionClient 保持一致:DoNotParseResponse 模式(readBody 依赖 RawBody)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return resty.New().
|
||||
SetTimeout(5*time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetCookieJar(jar).
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")
|
||||
}
|
||||
|
||||
// 模拟华侨大学登录页 HTML(账密字段位于 #pwdLoginDiv,含 pwdEncryptSalt)
|
||||
const hquLoginPageHTML = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<form method="post" id="loginFromId_fido" class="loginFromClass_fido" action="/authserver/login">
|
||||
<input type="hidden" name="_eventId" value="submit"/>
|
||||
<input type="text" name="username" value=""/>
|
||||
<input type="hidden" name="responseJson" value=""/>
|
||||
<input type="hidden" id="cllt" name="cllt" value="userNameLoginFido"/>
|
||||
<input type="hidden" name="dllt" value="generalLogin"/>
|
||||
<input type="hidden" id="lt" name="lt" value=""/>
|
||||
<input type="hidden" name="execution" value="e1s1"/>
|
||||
</form>
|
||||
<form method="post" id="loginFromId" class="loginFromClass" action="/authserver/login">
|
||||
<input type="text" name="username" value=""/>
|
||||
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码"/>
|
||||
<input type="text" name="dynamicCode" value=""/>
|
||||
<input type="hidden" name="_eventId" value="submit"/>
|
||||
<input type="hidden" id="lt" name="lt" value=""/>
|
||||
<input type="hidden" id="cllt" name="cllt" value="dynamicLogin"/>
|
||||
<input type="hidden" name="dllt" value="generalLogin"/>
|
||||
<input type="hidden" name="execution" value="e1s1"/>
|
||||
</form>
|
||||
<form method="post" id="loginFromId" class="loginFromClass" action="/authserver/login">
|
||||
<div id="pwdLoginDiv">
|
||||
<input type="text" name="username" value=""/>
|
||||
<input type="password" name="userPassword" value=""/>
|
||||
<input type="hidden" name="password" value=""/>
|
||||
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码"/>
|
||||
<input type="hidden" name="_eventId" value="submit"/>
|
||||
<input type="hidden" id="lt" name="lt" value=""/>
|
||||
<input type="hidden" id="cllt" name="cllt" value="userNameLogin"/>
|
||||
<input type="hidden" name="dllt" value="generalLogin"/>
|
||||
<input type="hidden" name="execution" value="e1s1"/>
|
||||
</div>
|
||||
</form>
|
||||
<input type="hidden" id="pwdEncryptSalt" value="sQJL9rnqFD65soWK"/>
|
||||
</body></html>`
|
||||
@@ -0,0 +1,409 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
//
|
||||
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
|
||||
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
|
||||
//
|
||||
// 流程(对标旧项目 likedream-go login/sms.go):
|
||||
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
|
||||
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
|
||||
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
|
||||
//
|
||||
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
|
||||
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
|
||||
|
||||
const (
|
||||
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
|
||||
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
|
||||
encryptVerion = "first_v4"
|
||||
)
|
||||
|
||||
// SmsSendResult 发送短信验证码结果
|
||||
type SmsSendResult struct {
|
||||
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
|
||||
TipMsg string `json:"tipMsg"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
|
||||
type SmsLoginResult struct {
|
||||
AuthID string `json:"authId"`
|
||||
AuthStatus string `json:"authStatus"`
|
||||
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
|
||||
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
|
||||
DeviceStatus string `json:"deviceStatus"`
|
||||
FirstLogin bool `json:"firstLogin"`
|
||||
Mobile string `json:"mobile"`
|
||||
MsgTip string `json:"msgTip"`
|
||||
Name string `json:"name"`
|
||||
OpenID string `json:"openId"` // 学工号
|
||||
PersonID string `json:"personId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
|
||||
TenantName string `json:"tenantName"`
|
||||
Tgc string `json:"tgc"` // CASTGC(长期凭证)
|
||||
TgcStatus string `json:"tgcStatus"`
|
||||
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
|
||||
UserType string `json:"userType"`
|
||||
}
|
||||
|
||||
// smsCommonResponse 今日校园短信接口通用响应
|
||||
type smsCommonResponse[T any] struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func newSmsClient() *resty.Client {
|
||||
c := resty.New().
|
||||
SetTimeout(20*time.Second).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c.SetCookieJar(jar)
|
||||
return c
|
||||
}
|
||||
|
||||
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
|
||||
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
|
||||
if sessionToken != "" {
|
||||
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
|
||||
req.SetHeader("SessionToken", enc)
|
||||
req.SetHeader("sessionTokenKey", enc)
|
||||
}
|
||||
|
||||
cpdailyInfo := map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"systemName": "android",
|
||||
"userId": "",
|
||||
"appVersion": "9.8.8",
|
||||
"model": "Unknown",
|
||||
"lon": "0",
|
||||
"lat": "0",
|
||||
"cpdailyVersion": "9.8.8",
|
||||
"systemVersion": "11",
|
||||
}
|
||||
infoBytes, _ := json.Marshal(cpdailyInfo)
|
||||
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
|
||||
|
||||
req.SetHeaders(map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"deviceType": "1",
|
||||
"CpdailyClientType": "CPDAILY",
|
||||
"CpdailyStandAlone": "0",
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
|
||||
"CpdailyInfo": encodedInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
|
||||
func CreateDeviceID() string {
|
||||
s := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
if len(s) < 32 {
|
||||
s = s + strings.Repeat("0", 32-len(s))
|
||||
}
|
||||
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
|
||||
}
|
||||
|
||||
// SendSmsCode 发送手机短信验证码
|
||||
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
|
||||
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]string{
|
||||
"a": phoneEncrypted,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsSendURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[SmsSendResult]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析发送验证码响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
// 只有 status=200 才代表短信真正下发。上游在「号码当日验证次数已达上限」等
|
||||
// 情况下仍返回 errCode=0,但 status=2、tipMsg="验证次数已达上限,将于次日清零"
|
||||
// 且 countdown 只有 10 秒,此时短信不会发出——若按成功处理,就会出现
|
||||
// 「前端提示已发送、手机收不到」。
|
||||
if st := result.Data.Status; st != 0 && st != 200 {
|
||||
msg := result.Data.TipMsg
|
||||
if msg == "" {
|
||||
msg = "短信发送失败,请稍后重试"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
|
||||
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
|
||||
payload := map[string]any{
|
||||
"d": code,
|
||||
"e": "CPDAILY",
|
||||
"c": phone,
|
||||
"f": "86",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"a": encodedPayload,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsLoginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[string]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析登录响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
|
||||
// data 为 base64(AES(json))
|
||||
decoded, err := base64.StdEncoding.DecodeString(result.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
|
||||
}
|
||||
plain := AesDecrypt(decoded)
|
||||
|
||||
var loginData SmsLoginResult
|
||||
if err := json.Unmarshal(plain, &loginData); err != nil {
|
||||
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
|
||||
}
|
||||
if loginData.TenantID == "" {
|
||||
return nil, fmt.Errorf("登录响应缺少 tenantId")
|
||||
}
|
||||
return &loginData, nil
|
||||
}
|
||||
|
||||
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
|
||||
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
|
||||
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
|
||||
if school.WecloudUrl == "" {
|
||||
return nil, fmt.Errorf("学校无可用域名")
|
||||
}
|
||||
host := ensureURLScheme(strings.TrimSuffix(school.WecloudUrl, "/"))
|
||||
|
||||
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"tenantId": school.ID,
|
||||
"standAlone": "0",
|
||||
"sessionToken": loginData.SessionToken,
|
||||
}
|
||||
if loginData.Tgc != "" {
|
||||
presetCookies["CASTGC"] = loginData.Tgc
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, loginData.SessionToken)
|
||||
|
||||
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
|
||||
}
|
||||
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
// 从 jar 收集所有 cookie
|
||||
var cookies []*http.Cookie
|
||||
seen := map[string]bool{}
|
||||
hosts := []string{host, "https://api.campushoy.com"}
|
||||
for _, h := range hosts {
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
if c.Name == "" || seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
}
|
||||
if len(cookies) == 0 {
|
||||
return nil, fmt.Errorf("未获取到有效 cookie")
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
|
||||
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
|
||||
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
|
||||
sessionToken := cookieMap["sessionToken"]
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
|
||||
}
|
||||
// 防御:传入的 host 可能是裸域名(无协议),直接拼接会导致 unsupported protocol scheme ""。
|
||||
host = ensureURLScheme(strings.TrimSuffix(host, "/"))
|
||||
|
||||
client := newSmsClient()
|
||||
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
|
||||
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
|
||||
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
|
||||
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
|
||||
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
|
||||
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"standAlone": "0",
|
||||
"sessionToken": sessionToken,
|
||||
}
|
||||
if v := cookieMap["tenantId"]; v != "" {
|
||||
presetCookies["tenantId"] = v
|
||||
}
|
||||
if v := cookieMap["CASTGC"]; v != "" {
|
||||
presetCookies["CASTGC"] = v
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(strings.TrimSuffix(host, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, sessionToken)
|
||||
|
||||
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("续期请求失败: %w", err)
|
||||
}
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析续期响应失败: %w", err)
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
var cookies []*http.Cookie
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
|
||||
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
|
||||
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
|
||||
func newRefreshMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
const validTGT = "iap-test-TGT-abc"
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
|
||||
case r.URL.Path == "/iap/login":
|
||||
service := r.URL.Query().Get("service")
|
||||
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
|
||||
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/iap/login/pc.html")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
|
||||
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
|
||||
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
|
||||
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
|
||||
return
|
||||
}
|
||||
// 无有效会话:302 到 CAS 登录入口
|
||||
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
|
||||
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
|
||||
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
|
||||
srv := newRefreshMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
|
||||
"sessionToken": "st-test",
|
||||
"tenantId": "lzufe",
|
||||
"CASTGC": "iap-test-TGT-abc",
|
||||
}, srv.URL, "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("续期应成功,实际报错: %v", err)
|
||||
}
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "MOD_AUTH_CAS" {
|
||||
hasMOD = true
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
|
||||
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
|
||||
if _, err := RefreshCasCookie(context.Background(), map[string]string{
|
||||
"CASTGC": "iap-test-TGT-abc",
|
||||
}, "https://school.campusphere.net", "device-1"); err == nil {
|
||||
t.Fatal("缺少 sessionToken 应报错")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user