Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7639912d7c | |||
| 2f20eebccd | |||
| 3aee45be80 | |||
| 46c8a554ff | |||
| 1507b386bc | |||
| 1a15b359f8 | |||
| f23ab1a261 | |||
| 84a602ae3b | |||
| 35a2f1e432 | |||
| 98689f68f4 | |||
| 3632cb642e | |||
| 47401e7388 | |||
| c5fe95b3ec | |||
| 0f49e7b1da | |||
| 95988c3ca9 | |||
| edd2b248e2 | |||
| 985748a3d3 | |||
| c0ea9ecd8f | |||
| e7e106c606 | |||
| 240e1226ef | |||
| 643e706335 | |||
| 61a8d2b3fb | |||
| 6532025cc0 | |||
| 7a4507903f | |||
| a95c843bf7 | |||
| 764bae23fc | |||
| 1f4d601ffa | |||
| 5de7852786 | |||
| 98631afdc5 | |||
| bc4e739e09 | |||
| 9bffdb7026 | |||
| 42c8b66a01 | |||
| 81b2942cad | |||
| 4b1dea3d3c | |||
| 0d665ad0fe | |||
| 67d925bb91 | |||
| bf0e1656ab | |||
| 66d07781be | |||
| 99c16652a7 | |||
| cc4aa1c00e | |||
| 15eceea38a | |||
| a9dbc2661d | |||
| ebc94ec9a0 | |||
| 98cf8666f6 | |||
| ff3593f057 | |||
| 74422b1848 | |||
| 84f87cd168 | |||
| 386e9269bf | |||
| 17a71b1e2a | |||
| 1b4f029602 | |||
| 2246752724 | |||
| a303782ab4 | |||
| 2cac0cdfee | |||
| 75d334680b | |||
| 35fa5a6560 | |||
| dbd3c88d32 | |||
| 33e29573ca | |||
| 4a80382b60 | |||
| 8eaaf5781a | |||
| 111cc0439c | |||
| a97537660e | |||
| 664d8a2594 | |||
| 6ede88b01b | |||
| 6b42cff492 | |||
| cc1150c1ac | |||
| 032c8e526f | |||
| d442f538e1 | |||
| 20921c6317 | |||
| 097aed6f40 | |||
| a75d57f309 | |||
| fc3bfe09f4 |
@@ -6,6 +6,9 @@ backed/cmd/server/cpdaily-new
|
||||
backed/*.log
|
||||
backed/*.har
|
||||
|
||||
# 本地/生产配置文件(含敏感凭证,禁止入库;模板见 backed/config.toml.example)
|
||||
backed/config.toml
|
||||
|
||||
# Frontend build (rebuilt by Docker multi-stage)
|
||||
backed/dist/
|
||||
frondend/dist/
|
||||
|
||||
+21
-35
@@ -26,33 +26,18 @@ steps:
|
||||
|
||||
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
|
||||
|
||||
# 确保网络存在
|
||||
docker network create likedream-net 2>/dev/null || true
|
||||
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
|
||||
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
|
||||
|
||||
# ============================================================
|
||||
# 第一步:初始化 1Panel 站点配置(首次部署时才需要)
|
||||
# ============================================================
|
||||
# nginx 配置文件在 git clone 后位于 deploy/nginx/ 目录
|
||||
# 同步配置文件到 /opt/cpdaily-xiaozhushou/config.toml
|
||||
mkdir -p /opt/cpdaily-xiaozhushou
|
||||
cp backed/config.toml /opt/cpdaily-xiaozhushou/config.toml
|
||||
|
||||
if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then
|
||||
echo "=== 首次部署:创建 1Panel 站点配置 ==="
|
||||
docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log
|
||||
docker cp deploy/nginx/xiaozhushou.conf 1Panel-openresty-ESmn:/usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf
|
||||
docker cp deploy/nginx/api.conf 1Panel-openresty-ESmn:/www/sites/xiaozhushou/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " 1Panel 站点配置完成 ✓"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 第二步:读取当前代理端口,确定蓝绿状态
|
||||
# 第一步:读取当前反代端口,确定蓝绿状态
|
||||
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
|
||||
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
|
||||
# ============================================================
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
|
||||
/www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
|
||||
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前反代端口: $CURRENT_PORT ==="
|
||||
|
||||
if [ "$CURRENT_PORT" = "8884" ]; then
|
||||
PORT_ACTIVE=8884
|
||||
@@ -61,9 +46,8 @@ steps:
|
||||
PORT_ACTIVE=8885
|
||||
PORT_STANDBY=8884
|
||||
else
|
||||
echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885"
|
||||
PORT_ACTIVE=8884
|
||||
PORT_STANDBY=8885
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
|
||||
exit 1
|
||||
fi
|
||||
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||
|
||||
@@ -71,19 +55,20 @@ steps:
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 第三步:在备用端口启动新容器
|
||||
# 第二步:在备用端口启动新容器
|
||||
# ============================================================
|
||||
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
|
||||
docker run -d --name cpdaily-xiaozhushou-new \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_STANDBY:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第四步:健康检查
|
||||
# 第三步:健康检查
|
||||
# ============================================================
|
||||
HEALTHY=false
|
||||
for i in $(seq 1 15); do
|
||||
@@ -104,45 +89,46 @@ steps:
|
||||
echo " 新容器已就绪 ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:切换 1Panel 代理到备用端口
|
||||
# 第四步:切换 1Panel 反代到备用端口
|
||||
# ============================================================
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||
/www/sites/xiaozhushou/proxy/api.conf
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# ============================================================
|
||||
# 第六步:停止旧容器
|
||||
# 第五步:停止旧容器
|
||||
# ============================================================
|
||||
echo "=== 清理旧容器 ==="
|
||||
docker stop cpdaily-xiaozhushou 2>/dev/null || true
|
||||
docker rm cpdaily-xiaozhushou 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 第七步:在活跃端口启动正式容器
|
||||
# 第六步:在活跃端口启动正式容器
|
||||
# ============================================================
|
||||
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
|
||||
docker run -d --name cpdaily-xiaozhushou \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_ACTIVE:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第八步:切回 1Panel 代理到活跃端口
|
||||
# 第七步:切回 1Panel 反代到活跃端口
|
||||
# ============================================================
|
||||
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||
/www/sites/xiaozhushou/proxy/api.conf
|
||||
/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
|
||||
|
||||
@@ -24,6 +24,8 @@ RUN go build -ldflags="-s -w" -o server ./cmd/server/
|
||||
# ============================================================
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# 时区固定为北京时间(tzdata 已安装;进程层 main.go 另有 time.Local 兜底)
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=backend-builder /build/server /app/server
|
||||
COPY --from=frontend-builder /build/dist /app/dist
|
||||
WORKDIR /app
|
||||
|
||||
+54
-18
@@ -11,6 +11,7 @@ import (
|
||||
"cpdaily-new/internal/handler"
|
||||
"cpdaily-new/internal/middleware"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
@@ -22,20 +23,33 @@ func main() {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 全局时区固定为中国标准时间(必须在 database.Init 之前设置:
|
||||
// DSN 的 loc=Local 跟随 time.Local,影响数据库 datetime 读写序列化)。
|
||||
// 运行镜像若不设 TZ 默认 UTC,会让所有记录/查询/统计/输出偏移 8 小时
|
||||
// (前端展示为"美国时间"),每月 1 日与每日 0-8 点的边界计算也会错位。
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
log.Fatalf("加载时区 Asia/Shanghai 失败: %v", err)
|
||||
}
|
||||
time.Local = loc
|
||||
|
||||
// 连接数据库
|
||||
database.Init()
|
||||
|
||||
// 连接 Redis(验证码存储/限流)
|
||||
service.InitRedis()
|
||||
|
||||
// 启动限流记录定时清理(每5分钟清理一次)
|
||||
ratelimit.Cleanup(5 * time.Minute)
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Cpdaily New",
|
||||
// 信任 openresty 反代(host 网络,经 docker NAT 后源 IP 为 172.22.0.1),
|
||||
// 从 X-Forwarded-For 取真实客户端 IP(如待适配学校提交记录)
|
||||
TrustedProxies: []string{"172.22.0.0/16"},
|
||||
// 信任 openresty 反代(frpc 经 PROXY protocol 透传后,openresty 直接来源为 127.0.0.1;
|
||||
// 宿主机 docker NAT 场景来源为 172.22.0.1),从 X-Forwarded-For 取真实客户端 IP
|
||||
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
|
||||
EnableTrustedProxyCheck: true,
|
||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||
})
|
||||
|
||||
// 中间件
|
||||
@@ -49,11 +63,14 @@ func main() {
|
||||
auth.Get("/captcha", handler.GetCaptcha)
|
||||
auth.Get("/schools", handler.SearchSchools)
|
||||
auth.Post("/login", handler.Login)
|
||||
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("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
task := api.Group("/task", middleware.AuthRequired())
|
||||
@@ -71,15 +88,29 @@ 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("/apply", handler.ExecuteLeaveApply)
|
||||
|
||||
// CDK 兑换
|
||||
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||
cdk.Post("/redeem", handler.UserRedeemCDK)
|
||||
cdk.Get("/vip-info", handler.GetUserVipInfo)
|
||||
|
||||
// 设备绑定
|
||||
device := api.Group("/device")
|
||||
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
|
||||
device.Post("/submit", handler.SubmitDeviceCode)
|
||||
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
|
||||
device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice)
|
||||
|
||||
// 管理后台 API
|
||||
admin := api.Group("/admin")
|
||||
@@ -92,33 +123,37 @@ func main() {
|
||||
adminAuth := api.Group("/admin", middleware.AdminAuthRequired())
|
||||
adminAuth.Get("/cdks", handler.ListCDKs)
|
||||
adminAuth.Post("/cdks", handler.GenerateCDK)
|
||||
adminAuth.Get("/all-cdks", handler.ListAllCDKs)
|
||||
adminAuth.Put("/cdks/:id/status", handler.UpdateCDKStatus)
|
||||
// 全量数据仅超管可见(代理只能看自己生成的)
|
||||
adminAuth.Get("/all-cdks", middleware.AdminOnly(), handler.ListAllCDKs)
|
||||
adminAuth.Get("/agents", handler.ListSubAgents)
|
||||
adminAuth.Post("/agents", handler.CreateSubAgent)
|
||||
adminAuth.Put("/agents/:id", handler.UpdateSubAgent)
|
||||
adminAuth.Get("/agents/:id", handler.GetAgentDetail)
|
||||
adminAuth.Put("/agents/:id/password", handler.UpdateAgentPassword)
|
||||
adminAuth.Put("/agents/:id/status", handler.UpdateAgentStatus)
|
||||
adminAuth.Get("/all-agents", handler.ListAllAgents)
|
||||
adminAuth.Get("/agent/list", handler.ListAllAgents)
|
||||
adminAuth.Get("/all-agents", middleware.AdminOnly(), handler.ListAllAgents)
|
||||
adminAuth.Get("/agent/list", middleware.AdminOnly(), handler.ListAllAgents)
|
||||
|
||||
// 管理后台 - 用户管理(仅admin)
|
||||
adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers)
|
||||
adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail)
|
||||
adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus)
|
||||
// 任务执行日志查询(供"当天执行总次数"卡片点击查看)
|
||||
adminAuth.Get("/task-logs", middleware.AdminOnly(), handler.AdminListTaskLogs)
|
||||
|
||||
// 管理后台 - 公告
|
||||
adminAuth.Post("/announcements", handler.CreateAnnouncement)
|
||||
adminAuth.Get("/announcements", handler.ListAnnouncements)
|
||||
adminAuth.Get("/announcements/:id", handler.GetAnnouncement)
|
||||
adminAuth.Put("/announcements/:id", handler.UpdateAnnouncement)
|
||||
adminAuth.Delete("/announcements/:id", handler.DeleteAnnouncement)
|
||||
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
|
||||
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
|
||||
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
|
||||
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
|
||||
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
|
||||
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
|
||||
|
||||
// 管理后台 - 系统设置
|
||||
adminAuth.Get("/settings", handler.GetSettings)
|
||||
adminAuth.Post("/settings", handler.UpdateSetting)
|
||||
adminAuth.Put("/settings", handler.UpdateSetting)
|
||||
adminAuth.Post("/settings/test-mail", handler.TestMail)
|
||||
// 管理后台 - 系统设置(全站计费/奖励/SMTP 配置,仅超管)
|
||||
adminAuth.Get("/settings", middleware.AdminOnly(), handler.GetSettings)
|
||||
adminAuth.Post("/settings", middleware.AdminOnly(), handler.UpdateSetting)
|
||||
adminAuth.Put("/settings", middleware.AdminOnly(), handler.UpdateSetting)
|
||||
adminAuth.Post("/settings/test-mail", middleware.AdminOnly(), handler.TestMail)
|
||||
|
||||
// 管理后台 - 我的公告(代理/管理员视角,共用普通用户公告处理器,但 target_type=agent)
|
||||
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
|
||||
@@ -161,6 +196,7 @@ func main() {
|
||||
// 管理后台 - 待适配学校
|
||||
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
|
||||
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
|
||||
adminAuth.Post("/pending-schools/:id/reject", handler.AdminRejectSchool)
|
||||
|
||||
// 健康检查(用于蓝绿部署)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
[server]
|
||||
port = 8093
|
||||
mode = "release"
|
||||
|
||||
[database]
|
||||
host = "10.10.10.128"
|
||||
port = 3306
|
||||
user = "xiaozhushou"
|
||||
password = "bh7S4JzPVyY0VLJh44i"
|
||||
dbname = "xiaozhushou"
|
||||
|
||||
[jwt]
|
||||
secret = "cpdaily-new-jwt-secret-key-2026"
|
||||
accessExpire = 3600
|
||||
refreshExpire = 864000
|
||||
|
||||
[captcha]
|
||||
width = 150
|
||||
height = 46
|
||||
length = 4
|
||||
|
||||
[cpdaily]
|
||||
appVersion = "9.8.8"
|
||||
systemVersion = "iOS 17"
|
||||
calVersion = "first_v4"
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
|
||||
captchaBaseUrl = "http://10.10.10.128:8890"
|
||||
captchaApiKey = "baca122..."
|
||||
|
||||
[agent]
|
||||
systemCdkPrice = 10.0
|
||||
@@ -0,0 +1,45 @@
|
||||
# 配置文件模板:复制为 config.toml 并按环境填写真实值。
|
||||
# 注意:本文件随仓库分发,禁止填写任何真实凭证(数据库/Redis/JWT/第三方密钥)。
|
||||
# 生产配置存放于服务器 /opt/cpdaily-xiaozhushou/config.toml(容器以挂载方式读取),
|
||||
# 请勿将生产 config.toml 提交进 git。
|
||||
|
||||
[server]
|
||||
port = 8093
|
||||
mode = "release"
|
||||
|
||||
[database]
|
||||
host = "127.0.0.1"
|
||||
port = 3306
|
||||
user = "your_db_user"
|
||||
password = "your_db_password"
|
||||
dbname = "xiaozhushou"
|
||||
|
||||
[jwt]
|
||||
# 生产环境必须使用强随机密钥(openssl rand -hex 32),示例值仅用于本地开发,切勿用于生产。
|
||||
secret = "dev-only-change-me-please"
|
||||
accessExpire = 3600
|
||||
refreshExpire = 864000
|
||||
# MFA 通道登录短期 token 有效期(秒):与第三方 Cookie 对齐,默认 7200(2 小时)
|
||||
mfaAccessExpire = 7200
|
||||
|
||||
[captcha]
|
||||
width = 150
|
||||
height = 46
|
||||
length = 4
|
||||
|
||||
[cpdaily]
|
||||
appVersion = "9.8.8"
|
||||
systemVersion = "iOS 17"
|
||||
calVersion = "first_v4"
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
|
||||
captchaBaseUrl = "http://127.0.0.1:8890"
|
||||
captchaApiKey = "your_captcha_api_key"
|
||||
|
||||
[agent]
|
||||
systemCdkPrice = 10.0
|
||||
|
||||
[redis]
|
||||
host = "127.0.0.1"
|
||||
port = 6379
|
||||
password = "your_redis_password"
|
||||
db = 0
|
||||
+9
-5
@@ -6,11 +6,12 @@ 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/makiuchi-d/gozxing v0.1.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/time v0.6.0
|
||||
golang.org/x/image v0.46.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
@@ -19,6 +20,9 @@ require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -27,11 +31,11 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
)
|
||||
|
||||
+25
-6
@@ -8,6 +8,14 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
@@ -25,6 +33,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
|
||||
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
@@ -32,10 +42,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
@@ -51,6 +63,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -87,8 +101,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -107,8 +121,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=
|
||||
@@ -117,7 +132,11 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package assets 嵌入静态资源(go:embed 不支持 .. 相对路径,统一放本包供各处引用)。
|
||||
package assets
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// SourGummyDigits Sour Gummy ExtraBold 的 0-9 数字子集字体(SIL OFL 1.1 开源授权,
|
||||
// 可免费商用;来源:Google Fonts / 猫啃网),圆润软糖风格,用于图形验证码。
|
||||
// 经 fonttools pyftsubset 从完整字体(58KB)子集化提取——验证码仅需数字,
|
||||
// 嵌入体积从 58KB 缩至约 3KB,cmap 中仅保留 U+0030-0039。
|
||||
//
|
||||
//go:embed fonts/SourGummy-ExtraBold-Digits.ttf
|
||||
var SourGummyDigits []byte
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Sour Gummy Project Authors (https://github.com/eifetx/Sour-Gummy-Fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -12,10 +12,19 @@ type Config struct {
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Jwt JwtConfig `toml:"jwt"`
|
||||
Captcha CaptchaConfig `toml:"captcha"`
|
||||
Redis RedisConfig `toml:"redis"`
|
||||
Cpdaily CpdailyConfig `toml:"cpdaily"`
|
||||
Agent AgentConfig `toml:"agent"`
|
||||
}
|
||||
|
||||
// RedisConfig 验证码等缓存使用的 Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
Password string `toml:"password"`
|
||||
Db int `toml:"db"`
|
||||
}
|
||||
|
||||
// AgentConfig 代理系统配置
|
||||
type AgentConfig struct {
|
||||
SystemCdkPrice float64 `toml:"systemCdkPrice"` // CDK系统底价(元)
|
||||
@@ -38,6 +47,10 @@ type JwtConfig struct {
|
||||
Secret string `toml:"secret"`
|
||||
AccessExpire int `toml:"accessExpire"`
|
||||
RefreshExpire int `toml:"refreshExpire"`
|
||||
// MfaAccessExpire MFA 通道登录的短期 token 有效期(秒)。MFA 学校第三方会话无法
|
||||
// 自动续期(重新登录需人工输入验证码),token 有效期与第三方 Cookie 对齐;独立于
|
||||
// 普通登录的 accessExpire 便于单独调整(默认 7200 = 2 小时,未配置时自动采用)。
|
||||
MfaAccessExpire int `toml:"mfaAccessExpire"`
|
||||
}
|
||||
|
||||
type CaptchaConfig struct {
|
||||
@@ -78,6 +91,10 @@ func Load(path string) error {
|
||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||
Global.Jwt.Secret = v
|
||||
}
|
||||
// MFA 通道短期 token 有效期默认 2 小时(未配置时采用,部署新版本自动生效)
|
||||
if Global.Jwt.MfaAccessExpire <= 0 {
|
||||
Global.Jwt.MfaAccessExpire = 7200
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// 自动迁移
|
||||
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
|
||||
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.CpLeaveLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
|
||||
log.Fatalf("自动迁移失败: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,25 @@ type SysUserListItem struct {
|
||||
SignCount int `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
|
||||
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
|
||||
VipStatus string `json:"vipStatus"` // vip: active / expired / none
|
||||
VipExpireAt string `json:"vipExpireAt"` // VIP 到期时间,未开通为空
|
||||
TodayStatus string `json:"todayStatus"` // today: success / fail / none
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastLoginAt string `json:"lastLoginAt"`
|
||||
LastLoginIP string `json:"lastLoginIp"`
|
||||
}
|
||||
|
||||
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
|
||||
func inviterDisplay(username, realName string) string {
|
||||
if realName != "" {
|
||||
return realName
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
// ListUsers 获取用户列表(仅admin)
|
||||
@@ -130,6 +143,26 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请人信息(本页用户的 inviter_id 去重后一次查询)
|
||||
inviterNameMap := make(map[uint]string)
|
||||
if len(users) > 0 {
|
||||
inviterIDs := make([]uint, 0, len(users))
|
||||
seen := make(map[uint]bool)
|
||||
for _, u := range users {
|
||||
if u.InviterID != nil && !seen[*u.InviterID] {
|
||||
seen[*u.InviterID] = true
|
||||
inviterIDs = append(inviterIDs, *u.InviterID)
|
||||
}
|
||||
}
|
||||
if len(inviterIDs) > 0 {
|
||||
var inviters []model.SysUser
|
||||
database.DB.Select("id, username, real_name").Where("id IN ?", inviterIDs).Find(&inviters)
|
||||
for _, iv := range inviters {
|
||||
inviterNameMap[iv.ID] = inviterDisplay(iv.Username, iv.RealName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]SysUserListItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
lastLogin := ""
|
||||
@@ -140,6 +173,21 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
if todayStatus == "" {
|
||||
todayStatus = "none"
|
||||
}
|
||||
inviterName := ""
|
||||
if u.InviterID != nil {
|
||||
inviterName = inviterNameMap[*u.InviterID]
|
||||
}
|
||||
// VIP 状态:active=有效期内 / expired=已过期 / none=未开通
|
||||
vipStatus := "none"
|
||||
vipExpireAt := ""
|
||||
if u.VipExpireAt != nil {
|
||||
vipExpireAt = u.VipExpireAt.Format("2006-01-02 15:04")
|
||||
if u.VipExpireAt.After(time.Now()) {
|
||||
vipStatus = "active"
|
||||
} else {
|
||||
vipStatus = "expired"
|
||||
}
|
||||
}
|
||||
items = append(items, SysUserListItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
@@ -152,12 +200,17 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
SignCount: u.SignCount,
|
||||
InviteReward: u.InviteReward,
|
||||
InvitedCount: u.InvitedCount,
|
||||
InviterID: u.InviterID,
|
||||
InviterName: inviterName,
|
||||
TaskCount: taskCountMap[u.ID],
|
||||
ExecutedCount: executedMap[u.ID],
|
||||
VipStatus: vipStatus,
|
||||
VipExpireAt: vipExpireAt,
|
||||
TodayStatus: todayStatus,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
LastLoginAt: lastLogin,
|
||||
LastLoginIP: u.LastLoginIP,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,11 +250,14 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
var taskCount int64
|
||||
database.DB.Model(&model.CpTask{}).Where("user_id = ?", user.ID).Count(&taskCount)
|
||||
|
||||
// 统计被邀请人(通过邀请码查找)
|
||||
var invitees []model.SysUser
|
||||
database.DB.Where("invite_code = ?", user.InviteCode).Find(&invitees) // 查找使用该用户邀请码注册的人,不对,应该查找谁是被这个用户邀请的
|
||||
// 实际上我们无法直接知道谁是被这个用户邀请的,因为没有记录"被谁邀请"的字段
|
||||
// 可以通过统计该用户的 invited_count 来知道邀请人数
|
||||
// 邀请人信息(被谁邀请)
|
||||
var inviterName string
|
||||
if user.InviterID != nil {
|
||||
var inviter model.SysUser
|
||||
if err := database.DB.Select("id, username, real_name").First(&inviter, *user.InviterID).Error; err == nil {
|
||||
inviterName = inviterDisplay(inviter.Username, inviter.RealName)
|
||||
}
|
||||
}
|
||||
|
||||
lastLogin := ""
|
||||
if user.LastLoginAt != nil {
|
||||
@@ -228,6 +284,8 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
"signCount": user.SignCount,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"inviterId": user.InviterID,
|
||||
"inviterName": inviterName,
|
||||
"status": user.Status,
|
||||
"disabledReason": user.DisabledReason,
|
||||
"taskCount": taskCount,
|
||||
@@ -235,6 +293,7 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
"mobileType": user.MobileType,
|
||||
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"lastLoginAt": lastLogin,
|
||||
"lastLoginIp": user.LastLoginIP,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,3 +340,81 @@ func UpdateUserStatus(c *fiber.Ctx) error {
|
||||
"status": req.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminTaskLogItem 任务执行日志列表项(后台查看用)
|
||||
type AdminTaskLogItem struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
TaskType string `json:"taskType"`
|
||||
Title string `json:"title"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
IsSuccess bool `json:"isSuccess"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
|
||||
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
|
||||
func AdminListTaskLogs(c *fiber.Ctx) error {
|
||||
// 日期(yyyy-MM-dd,默认当天)
|
||||
date := strings.TrimSpace(c.Query("date", ""))
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
return utils.Fail(c, 400, "日期格式错误,应为 yyyy-MM-dd")
|
||||
}
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
// 执行状态筛选:all / success / fail
|
||||
status := c.Query("status", "all")
|
||||
if status != "all" && status != "success" && status != "fail" {
|
||||
status = "all"
|
||||
}
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
|
||||
// 分页
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
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)
|
||||
if status == "success" {
|
||||
query = query.Where("cp_task_logs.is_success = 1")
|
||||
} else if status == "fail" {
|
||||
query = query.Where("cp_task_logs.is_success = 0")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
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)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
+314
-55
@@ -1,13 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/config"
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,16 +24,22 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetCaptcha 获取图片验证码
|
||||
// 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()
|
||||
}
|
||||
ok, wait := service.AllowCaptchaToken(ip)
|
||||
if !ok {
|
||||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||||
}
|
||||
result, err := service.GenerateCaptcha()
|
||||
if err != nil {
|
||||
slog.Error("生成验证码失败", "err", err)
|
||||
return utils.FailServer(c, "生成验证码失败")
|
||||
}
|
||||
// 调试:记录验证码
|
||||
if code, ok := service.GetCaptchaDebug(result.CaptchaID); ok {
|
||||
slog.Info("验证码", "captchaId", result.CaptchaID[:8], "code", code)
|
||||
}
|
||||
return utils.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -80,7 +93,11 @@ func Login(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 执行 cpdaily 登录
|
||||
cookies, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端(携带邀请码,注册时同样生效)
|
||||
return startMFALogin(c, school, req.Username, req.Password, req.InviteCode, mfaCtx)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("cpdaily登录失败", "error", err)
|
||||
return c.Status(401).JSON(utils.Response{
|
||||
@@ -92,6 +109,107 @@ func Login(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
|
||||
}
|
||||
|
||||
// MFAVerifyRequest 提交多因子认证验证码
|
||||
type MFAVerifyRequest struct {
|
||||
MfaToken string `json:"mfaToken"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// MFAVerify 提交多因子认证验证码,完成登录
|
||||
// 前端在 /auth/login 返回 needMFA=true 后,引导用户输入今日校园收到的验证码并调用本接口
|
||||
func MFAVerify(c *fiber.Ctx) error {
|
||||
var req MFAVerifyRequest
|
||||
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" || req.Code == "" {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
|
||||
// 读取暂存的 MFA 会话(成功前不删除,允许验证码输错重试;TTL 到期自动失效)
|
||||
sess, err := loadMFASession(c.Context(), req.MfaToken)
|
||||
if err != nil {
|
||||
// HTTP 200 + 专用业务码 4283:前端关闭验证码弹窗并引导重新登录
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(sess.SchoolID)
|
||||
if err != nil {
|
||||
slog.Error("MFA验证: 获取学校详情失败", "schoolId", sess.SchoolID, "error", err)
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "获取学校信息失败,请稍后重试", nil)
|
||||
}
|
||||
|
||||
// 完成 MFA 认证并走完 CAS 票据交换
|
||||
cookies, err := cpd.CompleteMFALogin(c.Context(), sess.MFA, code)
|
||||
if err != nil {
|
||||
slog.Warn("MFA验证失败", "username", sess.Username, "error", err)
|
||||
if errors.Is(err, cpd.ErrMFASessionExpired) {
|
||||
// 会话失效:前端关闭验证码弹窗,引导重新登录
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
|
||||
}
|
||||
// 验证码错误或其他失败:弹窗保留,可重新输入(业务码 4282)
|
||||
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "验证失败: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
// 验证成功,清理暂存会话
|
||||
deleteMFASession(c.Context(), req.MfaToken)
|
||||
|
||||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 检查 MOD_ cookie
|
||||
hasMOD := false
|
||||
for _, cookie := range cookies {
|
||||
@@ -114,10 +232,10 @@ func Login(c *fiber.Ctx) error {
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 构建用户名: 学校简称 + 学号
|
||||
userName := buildUsername(school, req.Username)
|
||||
userName := buildUsername(school, username)
|
||||
|
||||
// 查找或创建用户
|
||||
user, err := findOrCreateUser(userName, req.Password, req.SchoolID, school.Name, req.Username, cookieStr, req.InviteCode)
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
|
||||
if err != nil {
|
||||
slog.Error("用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
@@ -136,30 +254,37 @@ func Login(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 cookie 和最后登录时间
|
||||
// 更新 cookie 和最后登录时间(含登录 IP)
|
||||
now := time.Now()
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
})
|
||||
|
||||
// 保存明文密码(用于 cookie 过期时自动重新登录)
|
||||
if err := saveRawPassword(user.ID, req.Password); err != nil {
|
||||
if err := saveRawPassword(user.ID, password); err != nil {
|
||||
slog.Error("保存密码失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
// 尝试从 cpdaily 获取用户主页信息并更新本地数据
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
|
||||
// 生成 JWT
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
// 生成 JWT:MFA 通道登录使用与第三方 Cookie 对齐的短期 token
|
||||
var tokenPair *service.TokenPair
|
||||
if shortToken {
|
||||
tokenPair, err = service.GenerateShortTokens(user.ID, user.Username, user.SchoolID, int64(config.Global.Jwt.MfaAccessExpire))
|
||||
} else {
|
||||
tokenPair, err = service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
}
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew, // 本次登录是否为新注册账号(前端据此展示首次使用引导)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,6 +340,19 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
return utils.Success(c, user)
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v2"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("guide_version", GuideCompleteVersion).Error; err != nil {
|
||||
return utils.FailServer(c, "记录失败,请稍后重试")
|
||||
}
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||||
@@ -232,14 +370,14 @@ func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||
|
||||
func syncProfileToUser(profile *cpd.CpdailyUserProfile) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"real_name": profile.Name,
|
||||
"gender": profile.Gender,
|
||||
"avatar": profile.Img,
|
||||
"academy": profile.Academy,
|
||||
"major": profile.Major,
|
||||
"class_name": profile.ClassName,
|
||||
"grade": profile.Grade,
|
||||
"mobile_phone": profile.MobilePhone,
|
||||
"real_name": profile.Name,
|
||||
"gender": profile.Gender,
|
||||
"avatar": profile.Img,
|
||||
"academy": profile.Academy,
|
||||
"major": profile.Major,
|
||||
"class_name": profile.ClassName,
|
||||
"grade": profile.Grade,
|
||||
"mobile_phone": profile.MobilePhone,
|
||||
"org_full_name": profile.OrgFullName,
|
||||
}
|
||||
}
|
||||
@@ -294,14 +432,14 @@ func getRegisterRewardAmount() float64 {
|
||||
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
|
||||
func createSystemAnnouncement(userID uint, userType string, title, content string) {
|
||||
announcement := model.Announcement{
|
||||
Title: title,
|
||||
Content: content,
|
||||
Source: "system",
|
||||
TargetType: userType,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Source: "system",
|
||||
TargetType: userType,
|
||||
TargetUserID: &userID,
|
||||
ForcePopup: true,
|
||||
LoopPopup: false,
|
||||
CreatedBy: 0, // 系统创建
|
||||
ForcePopup: true,
|
||||
LoopPopup: false,
|
||||
CreatedBy: 0, // 系统创建
|
||||
}
|
||||
if err := database.DB.Create(&announcement).Error; err != nil {
|
||||
slog.Error("创建系统公告失败", "error", err, "userId", userID)
|
||||
@@ -327,21 +465,22 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, error) {
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, nil
|
||||
return &user, false, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和邀请码
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
@@ -360,13 +499,13 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
@@ -387,6 +526,9 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
return nil
|
||||
}
|
||||
|
||||
// 记录邀请关系(后台用户管理可展示"被谁邀请")
|
||||
tx.Model(&user).Update("inviter_id", inviter.ID)
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&inviter).Updates(map[string]interface{}{
|
||||
@@ -424,7 +566,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
|
||||
if err != nil {
|
||||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
|
||||
@@ -441,7 +583,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
}
|
||||
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||
return &user, nil
|
||||
return &user, true, nil
|
||||
}
|
||||
|
||||
// GenerateMyInviteCode 生成/获取当前用户的邀请码
|
||||
@@ -456,10 +598,12 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
// 已有邀请码直接返回
|
||||
if user.InviteCode != "" {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -473,10 +617,12 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": code,
|
||||
"inviteReward": 0,
|
||||
"invitedCount": 0,
|
||||
"canRedeem": false,
|
||||
"inviteCode": code,
|
||||
"inviteReward": 0,
|
||||
"invitedCount": 0,
|
||||
"canRedeem": false,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -489,10 +635,12 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -533,3 +681,114 @@ func generateRandomCode(n int) string {
|
||||
}
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// ========== 多因子认证(MFA)会话暂存 ==========
|
||||
|
||||
const (
|
||||
mfaSessionKeyFmt = "mfa_session:%s"
|
||||
mfaSessionTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||||
type mfaSession struct {
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
}
|
||||
|
||||
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
|
||||
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
|
||||
if service.Redis == nil {
|
||||
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||||
}
|
||||
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return utils.FailServer(c, "生成验证会话失败")
|
||||
}
|
||||
mfaToken := hex.EncodeToString(tokenBytes)
|
||||
|
||||
// 密码 AES 加密后暂存,验证成功后用于注册/更新账号
|
||||
encPassword, err := encryptRawPassword(password)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成验证会话失败")
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(mfaSession{
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
InviteCode: inviteCode,
|
||||
MFA: mfaCtx,
|
||||
})
|
||||
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
|
||||
slog.Error("MFA会话暂存失败", "error", err)
|
||||
return utils.FailServer(c, "生成验证会话失败")
|
||||
}
|
||||
|
||||
slog.Info("MFA会话已创建", "school", school.Name, "username", username,
|
||||
"defaultType", mfaCtx.ReAuthType, "authMethods", len(mfaCtx.AuthMethods))
|
||||
// HTTP 200 + 专用业务码 4281:前端按 code 弹出「选择验证方式」弹窗(而非登录失败弹窗)
|
||||
return utils.RespondWithCode(c, utils.CodeMFARequired, "该校登录需多因子认证,请选择验证方式获取验证码", fiber.Map{
|
||||
"needMFA": true,
|
||||
"mfaToken": mfaToken,
|
||||
"expiresIn": int(mfaSessionTTL.Seconds()),
|
||||
"authMethods": mfaCtx.AuthMethods,
|
||||
"defaultType": mfaCtx.ReAuthType,
|
||||
})
|
||||
}
|
||||
|
||||
// loadMFASession 读取暂存的 MFA 会话(验证码输错可重试,成功后才删除)
|
||||
func loadMFASession(ctx context.Context, mfaToken string) (*mfaSession, error) {
|
||||
if service.Redis == nil || mfaToken == "" {
|
||||
return nil, fmt.Errorf("mfa session unavailable")
|
||||
}
|
||||
val, err := service.Redis.Get(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken)).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sess mfaSession
|
||||
if err := json.Unmarshal([]byte(val), &sess); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解密密码
|
||||
raw, err := decryptRawPassword(sess.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.Password = raw
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// deleteMFASession 删除暂存的 MFA 会话
|
||||
func deleteMFASession(ctx context.Context, mfaToken string) {
|
||||
if service.Redis == nil || mfaToken == "" {
|
||||
return
|
||||
}
|
||||
service.Redis.Del(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken))
|
||||
}
|
||||
|
||||
// updateMFASession 更新暂存的 MFA 会话(如用户切换验证方式后),TTL 顺延
|
||||
func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) error {
|
||||
if service.Redis == nil || mfaToken == "" || sess == nil {
|
||||
return fmt.Errorf("mfa session unavailable")
|
||||
}
|
||||
encPassword, err := encryptRawPassword(sess.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(mfaSession{
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
InviteCode: sess.InviteCode,
|
||||
MFA: sess.MFA,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.Redis.Set(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err()
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
|
||||
|
||||
// 执行 cpdaily 登录
|
||||
cookies, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
|
||||
if err != nil {
|
||||
cookies, mfaCtx, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
|
||||
if err != nil && !errors.Is(err, cpd.ErrMFARequired) {
|
||||
errMsg := err.Error()
|
||||
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
|
||||
|
||||
@@ -102,6 +102,11 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
|
||||
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
|
||||
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
|
||||
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
|
||||
}
|
||||
|
||||
// 检查 MOD_ cookie 确保登录成功
|
||||
hasMOD := false
|
||||
|
||||
+293
-35
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -18,8 +19,10 @@ import (
|
||||
|
||||
// GenerateCDKRequest 生成CDK请求
|
||||
type GenerateCDKRequest struct {
|
||||
Count int `json:"count"` // 生成数量,默认1
|
||||
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1
|
||||
Count int `json:"count"` // 生成数量,默认1
|
||||
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数(type=count),默认1
|
||||
Type string `json:"type"` // CDK类型:count=执行次数(默认)/ vip=VIP时长
|
||||
VipDays int `json:"vipDays"` // VIP天数(type=vip),默认30(一个月)
|
||||
}
|
||||
|
||||
// 获取基础扣费(从系统设置或配置)
|
||||
@@ -35,6 +38,22 @@ func getBaseDeduction() float64 {
|
||||
return config.Global.Agent.SystemCdkPrice
|
||||
}
|
||||
|
||||
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
|
||||
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
|
||||
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
|
||||
func getVipCardPrice(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
|
||||
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
|
||||
|
||||
// GenerateCDK 生成CDK
|
||||
func GenerateCDK(c *fiber.Ctx) error {
|
||||
agentID := c.Locals("adminId").(uint)
|
||||
@@ -43,32 +62,70 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
// CDK 类型校验(默认次数)
|
||||
if req.Type == "" {
|
||||
req.Type = "count"
|
||||
}
|
||||
if req.Type != "count" && req.Type != "vip" {
|
||||
return utils.Fail(c, 400, "CDK类型无效,必须为 count 或 vip")
|
||||
}
|
||||
if req.Count <= 0 {
|
||||
req.Count = 1
|
||||
}
|
||||
if req.Count > 100 {
|
||||
return utils.Fail(c, 400, "单次最多生成100个CDK")
|
||||
}
|
||||
if req.MaxRuns <= 0 {
|
||||
req.MaxRuns = 1
|
||||
}
|
||||
if req.MaxRuns > 999 {
|
||||
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||
}
|
||||
|
||||
// 获取代理信息
|
||||
var agent model.AgentUser
|
||||
if err := database.DB.First(&agent, agentID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "账号不存在")
|
||||
}
|
||||
|
||||
if agent.Status == "disabled" {
|
||||
return utils.Fail(c, 403, "账号已被禁用")
|
||||
}
|
||||
|
||||
baseDeduction := getBaseDeduction()
|
||||
cdkPrice := baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||
costTotal := baseDeduction * float64(req.MaxRuns) * agent.Rate * float64(req.Count)
|
||||
|
||||
var maxRuns int
|
||||
var vipDays int
|
||||
var unitPrice float64 // 单张CDK代理扣费
|
||||
var costPerCDK float64
|
||||
|
||||
if req.Type == "vip" {
|
||||
// VIP CDK:仅支持周卡(7天)/ 月卡(30天)两档
|
||||
if req.VipDays <= 0 {
|
||||
req.VipDays = 30
|
||||
}
|
||||
if req.VipDays != 7 && req.VipDays != 30 {
|
||||
return utils.Fail(c, 400, "VIP CDK 仅支持 7 天(周卡)或 30 天(月卡)")
|
||||
}
|
||||
maxRuns = 0
|
||||
vipDays = req.VipDays
|
||||
var vipPrice float64
|
||||
if req.VipDays == 7 {
|
||||
vipPrice = getVipWeekPrice()
|
||||
} else {
|
||||
vipPrice = getVipMonthPrice()
|
||||
}
|
||||
unitPrice = vipPrice * agent.Rate
|
||||
costPerCDK = vipPrice
|
||||
} else {
|
||||
// 次数 CDK
|
||||
if req.MaxRuns <= 0 {
|
||||
req.MaxRuns = 1
|
||||
}
|
||||
if req.MaxRuns > 999 {
|
||||
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||
}
|
||||
maxRuns = req.MaxRuns
|
||||
vipDays = 0
|
||||
unitPrice = baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||
costPerCDK = baseDeduction * float64(req.MaxRuns)
|
||||
}
|
||||
|
||||
cdkPrice := unitPrice
|
||||
costTotal := unitPrice * float64(req.Count)
|
||||
|
||||
// 事务:扣余额 + 生成CDK
|
||||
cdkCodes := make([]model.CDKCode, 0, req.Count)
|
||||
@@ -89,9 +146,11 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
cdk := model.CDKCode{
|
||||
Code: code,
|
||||
Price: cdkPrice,
|
||||
CostPrice: baseDeduction * float64(req.MaxRuns),
|
||||
CostPrice: costPerCDK,
|
||||
AgentID: agentID,
|
||||
MaxRuns: req.MaxRuns,
|
||||
MaxRuns: maxRuns,
|
||||
Type: req.Type,
|
||||
VipDays: vipDays,
|
||||
Status: "unused",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -123,22 +182,154 @@ func GenerateCDK(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListCDKs 获取自己生成的CDK列表
|
||||
// ListCDKs 获取自己生成的CDK列表(分页)
|
||||
func ListCDKs(c *fiber.Ctx) error {
|
||||
agentID := c.Locals("adminId").(uint)
|
||||
|
||||
var cdks []model.CDKCode
|
||||
database.DB.Where("agent_id = ?", agentID).Order("created_at DESC").Limit(100).Find(&cdks)
|
||||
|
||||
return utils.Success(c, cdks)
|
||||
return listCDKs(c, "agent", agentID)
|
||||
}
|
||||
|
||||
// ListAllCDKs 获取所有CDK列表(仅admin)
|
||||
func ListAllCDKs(c *fiber.Ctx) error {
|
||||
var cdks []model.CDKCode
|
||||
database.DB.Preload("Agent").Order("created_at DESC").Limit(200).Find(&cdks)
|
||||
return listCDKs(c, "all", 0)
|
||||
}
|
||||
|
||||
return utils.Success(c, cdks)
|
||||
// CDKSummary CDK 状态汇总
|
||||
type CDKSummary struct {
|
||||
Total int64 `json:"total"`
|
||||
Unused int64 `json:"unused"`
|
||||
Used int64 `json:"used"`
|
||||
Disabled int64 `json:"disabled"`
|
||||
}
|
||||
|
||||
// listCDKs 分页查询CDK(scope: agent=自己 / all=全部)
|
||||
func listCDKs(c *fiber.Ctx, scope string, agentID uint) error {
|
||||
// 分页
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 筛选:状态 / CDK码关键字
|
||||
status := strings.TrimSpace(c.Query("status", ""))
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
|
||||
base := func() *gorm.DB {
|
||||
q := database.DB.Model(&model.CDKCode{})
|
||||
if scope == "agent" {
|
||||
q = q.Where("agent_id = ?", agentID)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
countQuery := base()
|
||||
if status != "" {
|
||||
countQuery = countQuery.Where("status = ?", status)
|
||||
}
|
||||
if keyword != "" {
|
||||
countQuery = countQuery.Where("code LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
countQuery.Count(&total)
|
||||
|
||||
// 状态汇总(不受筛选影响,按当前可见范围统计)
|
||||
summary := CDKSummary{}
|
||||
for _, s := range []string{"unused", "used", "disabled"} {
|
||||
var n int64
|
||||
base().Where("status = ?", s).Count(&n)
|
||||
switch s {
|
||||
case "unused":
|
||||
summary.Unused = n
|
||||
case "used":
|
||||
summary.Used = n
|
||||
case "disabled":
|
||||
summary.Disabled = n
|
||||
}
|
||||
}
|
||||
summary.Total = summary.Unused + summary.Used + summary.Disabled
|
||||
|
||||
// 数据
|
||||
var cdks []model.CDKCode
|
||||
dataQuery := base()
|
||||
if status != "" {
|
||||
dataQuery = dataQuery.Where("status = ?", status)
|
||||
}
|
||||
if keyword != "" {
|
||||
dataQuery = dataQuery.Where("code LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
dataQuery.Order("created_at DESC").Offset(offset).Limit(pageSize)
|
||||
if scope == "all" {
|
||||
dataQuery = dataQuery.Preload("Agent")
|
||||
}
|
||||
dataQuery.Find(&cdks)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"total": total,
|
||||
"items": cdks,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"summary": summary,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCDKStatusRequest 禁用/恢复CDK请求
|
||||
type UpdateCDKStatusRequest struct {
|
||||
Action string `json:"action"` // disable / enable
|
||||
}
|
||||
|
||||
// UpdateCDKStatus 禁用/恢复CDK(代理仅能操作自己生成的,admin可操作全部)
|
||||
func UpdateCDKStatus(c *fiber.Ctx) error {
|
||||
cdkID := c.Params("id")
|
||||
adminID := c.Locals("adminId").(uint)
|
||||
role, _ := c.Locals("adminRole").(string)
|
||||
|
||||
var req UpdateCDKStatusRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
if req.Action != "disable" && req.Action != "enable" {
|
||||
return utils.Fail(c, 400, "操作无效,必须为 disable 或 enable")
|
||||
}
|
||||
|
||||
var cdk model.CDKCode
|
||||
if err := database.DB.First(&cdk, cdkID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "CDK不存在")
|
||||
}
|
||||
|
||||
// 权限:非 admin 只能操作自己生成的 CDK
|
||||
if role != "admin" && cdk.AgentID != adminID {
|
||||
return utils.Fail(c, 403, "无权操作该CDK")
|
||||
}
|
||||
|
||||
if req.Action == "disable" {
|
||||
if cdk.Status == "disabled" {
|
||||
return utils.Fail(c, 400, "该CDK已是禁用状态")
|
||||
}
|
||||
if cdk.Status != "unused" {
|
||||
return utils.Fail(c, 400, "仅未使用的CDK可以禁用")
|
||||
}
|
||||
if err := database.DB.Model(&cdk).Update("status", "disabled").Error; err != nil {
|
||||
slog.Error("禁用CDK失败", "error", err, "cdkId", cdk.ID)
|
||||
return utils.FailServer(c, "操作失败")
|
||||
}
|
||||
slog.Info("禁用CDK成功", "cdkId", cdk.ID, "code", cdk.Code, "adminId", adminID)
|
||||
return utils.Success(c, fiber.Map{"id": cdk.ID, "status": "disabled"})
|
||||
}
|
||||
|
||||
// enable:仅 disabled 可恢复为 unused
|
||||
if cdk.Status != "disabled" {
|
||||
return utils.Fail(c, 400, "仅已禁用的CDK可以恢复")
|
||||
}
|
||||
if err := database.DB.Model(&cdk).Update("status", "unused").Error; err != nil {
|
||||
slog.Error("恢复CDK失败", "error", err, "cdkId", cdk.ID)
|
||||
return utils.FailServer(c, "操作失败")
|
||||
}
|
||||
slog.Info("恢复CDK成功", "cdkId", cdk.ID, "code", cdk.Code, "adminId", adminID)
|
||||
return utils.Success(c, fiber.Map{"id": cdk.ID, "status": "unused"})
|
||||
}
|
||||
|
||||
// UserRedeemCDKRequest 用户兑换CDK请求
|
||||
@@ -178,6 +369,9 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 使用行锁(悲观锁)确保并发安全
|
||||
var vipExpireAt time.Time
|
||||
redeemType := cdk.Type
|
||||
redeemRuns := cdk.MaxRuns
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 重新查询并锁定该行
|
||||
var lockedCDK model.CDKCode
|
||||
@@ -187,28 +381,54 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
if lockedCDK.Status != "unused" {
|
||||
return errors.New("CDK码已被使用")
|
||||
}
|
||||
if lockedCDK.MaxRuns <= 0 {
|
||||
return errors.New("CDK码已无可运行次数")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// 一次性消耗全部次数
|
||||
totalRuns := lockedCDK.MaxRuns
|
||||
updates := map[string]interface{}{
|
||||
"status": "used",
|
||||
"user_id": userID,
|
||||
"used_at": now,
|
||||
}
|
||||
|
||||
if lockedCDK.Type == "vip" {
|
||||
// ===== VIP CDK:延长 VIP 到期时间 =====
|
||||
if lockedCDK.VipDays <= 0 {
|
||||
return errors.New("VIP CDK配置无效")
|
||||
}
|
||||
// 锁定用户行,防止并发兑换导致到期时间计算错误
|
||||
var lockedUser model.SysUser
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedUser, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 已开通且未过期:在原到期时间上顺延;否则从当前时间起算
|
||||
base := now
|
||||
if lockedUser.VipExpireAt != nil && lockedUser.VipExpireAt.After(base) {
|
||||
base = *lockedUser.VipExpireAt
|
||||
}
|
||||
vipExpireAt = base.AddDate(0, 0, lockedCDK.VipDays)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("vip_expire_at", vipExpireAt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// ===== 次数 CDK:一次性消耗全部次数 =====
|
||||
if lockedCDK.MaxRuns <= 0 {
|
||||
return errors.New("CDK码已无可运行次数")
|
||||
}
|
||||
redeemRuns = lockedCDK.MaxRuns
|
||||
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count + ?", redeemRuns)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count + ?", totalRuns)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -219,15 +439,30 @@ func UserRedeemCDK(c *fiber.Ctx) error {
|
||||
if err.Error() == "CDK码已无可运行次数" {
|
||||
return utils.Fail(c, 400, "CDK码已无可运行次数")
|
||||
}
|
||||
if err.Error() == "VIP CDK配置无效" {
|
||||
return utils.Fail(c, 400, "VIP CDK配置无效,请联系管理员")
|
||||
}
|
||||
slog.Error("兑换CDK失败", "error", err)
|
||||
return utils.FailServer(c, "兑换失败")
|
||||
}
|
||||
|
||||
// 按 CDK 类型返回不同提示
|
||||
if redeemType == "vip" {
|
||||
slog.Info("VIP CDK兑换成功", "userId", userID, "code", req.Code, "vipDays", cdk.VipDays, "newExpireAt", vipExpireAt.Format("2006-01-02 15:04:05"))
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "VIP兑换成功",
|
||||
"type": "vip",
|
||||
"vipDays": cdk.VipDays,
|
||||
"vipExpireAt": vipExpireAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("CDK兑换成功", "userId", userID, "code", req.Code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "兑换成功",
|
||||
"totalRuns": cdk.MaxRuns,
|
||||
"type": "count",
|
||||
"totalRuns": redeemRuns,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,3 +481,26 @@ func generateCDKCode() string {
|
||||
func formatFloat(f float64) string {
|
||||
return utils.FormatFloat(f)
|
||||
}
|
||||
|
||||
// GetUserVipInfo 获取当前用户 VIP 信息(到期时间 + 售价,供个人中心展示)
|
||||
func GetUserVipInfo(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.Select("id, vip_expire_at").First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
vipExpireAt := ""
|
||||
if user.VipExpireAt != nil {
|
||||
vipExpireAt = user.VipExpireAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"isVip": isVip,
|
||||
"vipExpireAt": vipExpireAt,
|
||||
"vipWeekPrice": formatFloat(getVipWeekPrice()),
|
||||
"vipMonthPrice": formatFloat(getVipMonthPrice()),
|
||||
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,6 +137,32 @@ func SubmitDeviceCode(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "bound"})
|
||||
}
|
||||
|
||||
// RandomBindDevice 随机生成设备码并绑定到当前用户(个人中心「随机」按钮)
|
||||
// 风险:与真实手机绑定的设备码不一致,执行签到时现有手机会提示「频繁切换设备」导致无法签到,
|
||||
// 前端已做连续两次确认;此处不再重复校验,直接生成 UUID(大写,与真实设备码格式一致)绑定。
|
||||
func RandomBindDevice(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return utils.FailServer(c, "生成设备码失败")
|
||||
}
|
||||
// 标准 UUID v4 格式(大写),与真实设备码样式一致
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
deviceCode := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
|
||||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"mobile_code": deviceCode,
|
||||
"mobile_type": "manual",
|
||||
}).Error; err != nil {
|
||||
return utils.FailServer(c, "绑定失败")
|
||||
}
|
||||
|
||||
slog.Info("随机绑定设备码", "userId", userID, "deviceCode", deviceCode)
|
||||
return utils.Success(c, fiber.Map{"deviceCode": deviceCode})
|
||||
}
|
||||
|
||||
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
|
||||
func ClosePage(c *fiber.Ctx) error {
|
||||
c.Type("html")
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---------- 销假(请假销假)----------
|
||||
|
||||
// GetPendingLeaves 获取今日校园待销假列表(实时请求今日校园,附带规定销假位置)
|
||||
func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
items, err := cpd.FetchLeaveList(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
items, err = cpd.FetchLeaveList(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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 时为必填)
|
||||
}
|
||||
|
||||
// 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, "请先登录")
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户(销假为同步执行,无任务维度,按用户维度限流)
|
||||
rateKey := fmt.Sprintf("leave:%d", userID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("销假触发限流", "userId", userID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 连接成功
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假详情失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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": errors.Is(err, ErrPasswordChanged)})
|
||||
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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "销假失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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": errors.Is(err, ErrPasswordChanged)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "销假成功!",
|
||||
})
|
||||
} 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)
|
||||
|
||||
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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---------- 请假申请 ----------
|
||||
|
||||
// loadLeaveUser 读取当前用户(请假链路公共前置)
|
||||
func loadLeaveUser(c *fiber.Ctx) (*model.SysUser, error) {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return nil, fmt.Errorf("请先登录")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetLeaveTypes 获取请假类型列表(含全部权威开关,前端据此渲染表单)
|
||||
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)
|
||||
|
||||
types, err := cpd.FetchLeaveTypes(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
types, err = cpd.FetchLeaveTypes(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假类型失败", "userId", user.ID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, types)
|
||||
}
|
||||
|
||||
// GetLeaveApplyApprovers 获取请假可选审批人(needApproval=1 的类型)。
|
||||
// 请求:{ leaveType, startTime, endTime }(与销假审批人接口区分:此处选择类型发生在提交前)。
|
||||
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)
|
||||
|
||||
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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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"` // 类型编码
|
||||
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"` // 定位地址
|
||||
ImageURL string `json:"imageUrl"` // 附件(可选)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid
|
||||
ApproverId string `json:"approverId"` // 审批人 userId
|
||||
ApproverName string `json:"approverName"` // 审批人姓名
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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, "请先登录")
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 类型配置(拿 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: model.LeaveActionApply,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:与销假同规则(同一用户维度)
|
||||
rateKey := fmt.Sprintf("leave-apply:%d", userID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("请假触发限流", "userId", userID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假类型配置失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
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
|
||||
}
|
||||
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
|
||||
errMsg := "该请假类型需要选择审批人"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
|
||||
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,
|
||||
ImageURL: req.ImageURL,
|
||||
ApproverWid: req.ApproverWid,
|
||||
ApproverId: req.ApproverId,
|
||||
ApproverName: req.ApproverName,
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
|
||||
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "请假申请失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("请假申请失败", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "请假申请提交成功!",
|
||||
})
|
||||
} 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
|
||||
}
|
||||
@@ -133,15 +133,16 @@ type ReportFailedLoginRequest struct {
|
||||
}
|
||||
|
||||
type PendingSchoolRes struct {
|
||||
ID uint `json:"id"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID uint `json:"id"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
RejectReason string `json:"rejectReason"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ---------- 用户端 API ----------
|
||||
@@ -196,7 +197,9 @@ func ReportFailedLogin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
// 注意:Truncate(24h) 按 UTC 午夜截断会偏移 8 小时,必须按北京时间当日 0 点
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todayLoginCount int64
|
||||
database.DB.Model(&model.SysUser{}).
|
||||
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
|
||||
@@ -266,6 +269,8 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||
query = query.Where("status = ?", "pending")
|
||||
} else if status == "completed" {
|
||||
query = query.Where("status = ?", "completed")
|
||||
} else if status == "rejected" {
|
||||
query = query.Where("status = ?", "rejected")
|
||||
}
|
||||
if err := query.Find(&records).Error; err != nil {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
@@ -278,15 +283,16 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||
ip = "-"
|
||||
}
|
||||
items = append(items, PendingSchoolRes{
|
||||
ID: r.ID,
|
||||
SchoolID: r.SchoolID,
|
||||
SchoolName: r.SchoolName,
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
Email: r.Email,
|
||||
IP: ip,
|
||||
Status: r.Status,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: r.ID,
|
||||
SchoolID: r.SchoolID,
|
||||
SchoolName: r.SchoolName,
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
Email: r.Email,
|
||||
IP: ip,
|
||||
Status: r.Status,
|
||||
RejectReason: r.RejectReason,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,3 +337,49 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
|
||||
|
||||
return utils.Success(c, fiber.Map{"completed": true})
|
||||
}
|
||||
|
||||
// AdminRejectSchool 拒绝某条待适配学校记录(拒绝必须说明理由,理由将邮件通知联系邮箱)
|
||||
func AdminRejectSchool(c *fiber.Ctx) error {
|
||||
recordID, err := c.ParamsInt("id")
|
||||
if err != nil {
|
||||
return utils.FailParam(c, "无效的记录ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
if req.Reason == "" {
|
||||
return utils.FailParam(c, "拒绝时必须说明理由")
|
||||
}
|
||||
if len([]rune(req.Reason)) > 500 {
|
||||
return utils.FailParam(c, "拒绝理由不能超过500字")
|
||||
}
|
||||
|
||||
var record model.PendingSchool
|
||||
if err := database.DB.First(&record, recordID).Error; err != nil {
|
||||
return utils.Fail(c, 404, "记录不存在")
|
||||
}
|
||||
if record.Status != "pending" {
|
||||
return utils.Fail(c, 400, "该记录已处理,无法拒绝")
|
||||
}
|
||||
|
||||
// 仅拒绝当前这条记录(单条拒绝,不影响同校其他记录)
|
||||
database.DB.Model(&record).Updates(map[string]interface{}{
|
||||
"status": "rejected",
|
||||
"reject_reason": req.Reason,
|
||||
})
|
||||
|
||||
// 邮件只通知该条记录留下的联系邮箱(附拒绝理由)
|
||||
contactEmails := service.SplitEmailsList(record.Email)
|
||||
|
||||
slog.Info("待适配学校记录已被拒绝", "recordId", record.ID, "schoolId", record.SchoolID, "schoolName", record.SchoolName, "reason", req.Reason)
|
||||
|
||||
// 异步邮件通知该条记录的联系邮箱(附拒绝理由)
|
||||
service.NotifySchoolRejected(contactEmails, record.SchoolName, req.Reason)
|
||||
|
||||
return utils.Success(c, fiber.Map{"rejected": true})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
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"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
}
|
||||
@@ -60,15 +66,17 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"register_reward": true,
|
||||
"vip_price_week": true,
|
||||
"vip_price_month": true,
|
||||
// 邮件通知(SMTP)
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
"smtp_user": true,
|
||||
"smtp_password": true,
|
||||
"smtp_from": true,
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
"smtp_user": true,
|
||||
"smtp_password": true,
|
||||
"smtp_from": true,
|
||||
"smtp_from_name": true,
|
||||
"smtp_secure": true,
|
||||
"admin_emails": true,
|
||||
"smtp_secure": true,
|
||||
"admin_emails": true,
|
||||
}
|
||||
if !allowedKeys[req.Key] {
|
||||
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||
|
||||
+111
-26
@@ -157,14 +157,14 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 从 cpdaily 拉取任务详情
|
||||
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err := cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
if err != nil {
|
||||
// cookie 过期时自动尝试重新登录
|
||||
if isCookieExpired(err) {
|
||||
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,30 @@ type SaveTaskFormRequest struct {
|
||||
FormData []model.CpTaskFormItem `json:"formData"`
|
||||
}
|
||||
|
||||
// missingRequiredFormFields 返回表单中「必填但内容为空」的字段标题列表。
|
||||
// 学校表单模板中必填项为空时提交必然失败(如签到地点未配置 → 服务端报"签到地点纬度不能为空"),
|
||||
// 因此在执行前拦截并给出明确提示,而不是等提交失败后只报笼统错误。
|
||||
func missingRequiredFormFields(formData string) []string {
|
||||
var items []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(formData), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
for _, it := range items {
|
||||
if !it.Required || !it.Show {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(it.Content) == "" {
|
||||
name := it.Title
|
||||
if name == "" {
|
||||
name = it.ColName
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func SaveTaskForm(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -265,6 +289,29 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
var tasks []model.CpTask
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
|
||||
|
||||
// 跨天惰性重置:签到/查寝均为每日任务,"已完成(success)"只应代表当天成功。
|
||||
// 昨日及更早的 success 若继续保留,任务卡会一直显示绿色对勾、隐藏执行按钮,
|
||||
// 误导用户"今天已执行"且无法再次打卡。以"当天是否存在成功日志"为准判定并重置回 pending。
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
database.DB.Model(&model.CpTaskLog{}).
|
||||
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
|
||||
Distinct("cp_task_id").
|
||||
Pluck("cp_task_id", &todaySuccessIDs)
|
||||
todaySuccessSet := make(map[uint]bool, len(todaySuccessIDs))
|
||||
for _, tid := range todaySuccessIDs {
|
||||
todaySuccessSet[tid] = true
|
||||
}
|
||||
for i := range tasks {
|
||||
if tasks[i].TaskStatus == "success" && !todaySuccessSet[tasks[i].ID] {
|
||||
tasks[i].TaskStatus = "pending"
|
||||
tasks[i].ErrorMessage = ""
|
||||
database.DB.Model(&model.CpTask{}).Where("id = ?", tasks[i].ID).
|
||||
Updates(map[string]interface{}{"task_status": "pending", "error_message": ""})
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取今日任务进行匹配
|
||||
var user model.SysUser
|
||||
var todayTasks []cpd.TodayTaskInfo
|
||||
@@ -359,6 +406,9 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if task.FormData == "" {
|
||||
return utils.Fail(c, 400, "请先配置表单数据")
|
||||
}
|
||||
if missing := missingRequiredFormFields(task.FormData); len(missing) > 0 {
|
||||
return utils.Fail(c, 400, "签到信息未配置完整,缺少:"+strings.Join(missing, "、")+",请编辑任务补全后再执行")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
@@ -393,8 +443,11 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
// 检查可用次数
|
||||
if user.SignCount <= 0 {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
@@ -425,14 +478,14 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 2. 获取表单详情
|
||||
// 2. 获取表单详情(按任务名从当天列表实时匹配实例,wid 每次执行时自动获取)
|
||||
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
|
||||
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err := cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
@@ -453,15 +506,10 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
|
||||
if task.RealTitle == "" {
|
||||
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
|
||||
for _, t := range todayTasks {
|
||||
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
|
||||
task.RealTitle = t.TaskName
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
break
|
||||
}
|
||||
}
|
||||
// 详情来自当天列表匹配,RealTitle 即列表中的真实任务名
|
||||
if task.RealTitle == "" && detail.RealTitle != "" && detail.RealTitle != task.Title {
|
||||
task.RealTitle = detail.RealTitle
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 反序列化用户配置的表单
|
||||
@@ -480,7 +528,37 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
// 4. 填充表单并提交
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
|
||||
|
||||
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
submitForm, formErr := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||
if formErr != nil {
|
||||
errMsg := "生成签到表单失败: " + formErr.Error()
|
||||
slog.Error("生成签到表单失败", "taskId", id, "error", formErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
previewUrl, upErr := cpd.UploadDataURIImage(cookieMap, host, cpd.TaskType(task.TaskType), photo)
|
||||
if upErr != nil {
|
||||
errMsg := "照片上传失败: " + upErr.Error()
|
||||
slog.Error("照片上传失败", "taskId", id, "error", upErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
submitForm["signPhotoUrl"] = previewUrl
|
||||
}
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
|
||||
@@ -512,15 +590,22 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
})
|
||||
saveLog(true, "", taskTitle(task))
|
||||
|
||||
// 扣减可用次数(原子操作,防止并发超卖)
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
})
|
||||
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
|
||||
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -569,7 +654,7 @@ var _ = time.Now
|
||||
|
||||
// UpdateTaskRequest 更新任务请求
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Title *string `json:"title"`
|
||||
FormData *[]model.CpTaskFormItem `json:"formData"`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"fmt"
|
||||
|
||||
@@ -117,6 +118,12 @@ func CreateTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "创建工单失败")
|
||||
}
|
||||
|
||||
// 异步邮件提醒管理员处理新工单
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id, username, real_name, school_name").First(&u, userID).Error; err == nil {
|
||||
service.NotifyNewTicket(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, ticket.Content, ticket.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": ticket.ID,
|
||||
})
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// touchDailyLogin 补齐“当天已登录”标记:JWT 有效期较长且可静默续期,
|
||||
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
|
||||
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
|
||||
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
|
||||
// 写入当天登录时间的同时同步记录该次访问的客户端 IP(utils.ClientIP),供后台列表展示来源。
|
||||
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
|
||||
func touchDailyLogin(c *fiber.Ctx, userID uint) {
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
database.DB.Model(&model.SysUser{}).
|
||||
Where("id = ? AND (last_login_at IS NULL OR last_login_at < ?)", userID, todayStart).
|
||||
Updates(map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
})
|
||||
}
|
||||
|
||||
func AuthRequired() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
tokenStr := ""
|
||||
@@ -38,6 +58,8 @@ func AuthRequired() fiber.Handler {
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
c.Locals("schoolId", claims.SchoolID)
|
||||
// 当天首次使用平台时把 last_login_at 顶到今天并记录 IP(详见 touchDailyLogin 注释)
|
||||
touchDailyLogin(c, claims.UserID)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,22 @@ import "time"
|
||||
|
||||
// CDKCode CDK码
|
||||
type CDKCode struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数
|
||||
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
|
||||
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
|
||||
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数(type=count 有效)
|
||||
// CDK 类型:count=执行次数 / vip=VIP 时长
|
||||
Type string `gorm:"type:varchar(16);not null;default:count;index" json:"type"`
|
||||
// VIP 时长天数(type=vip 有效,默认 30 天/一个月)
|
||||
VipDays int `gorm:"default:0" json:"vipDays"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
|
||||
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
|
||||
UsedAt *time.Time `json:"usedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (CDKCode) TableName() string {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// CpLeaveLog 请假/销假执行日志
|
||||
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"`
|
||||
}
|
||||
|
||||
func (CpLeaveLog) TableName() string {
|
||||
return "cp_leave_logs"
|
||||
}
|
||||
|
||||
// 日志动作类型:请假申请 / 销假
|
||||
const (
|
||||
LeaveActionApply = "apply" // 请假申请
|
||||
LeaveActionReport = "report" // 销假
|
||||
)
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
|
||||
// PendingSchool 待适配学校(用户提交的测试账号)
|
||||
type PendingSchool struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||
Username string `gorm:"size:100;not null" json:"username"`
|
||||
Password string `gorm:"size:200;not null" json:"password"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||
Username string `gorm:"size:100;not null" json:"username"`
|
||||
Password string `gorm:"size:200;not null" json:"password"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||
RejectReason string `gorm:"size:500" json:"rejectReason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (PendingSchool) TableName() string {
|
||||
|
||||
@@ -3,15 +3,15 @@ package model
|
||||
import "time"
|
||||
|
||||
type SysUser struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||
Cookie string `gorm:"type:longtext" json:"-"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||
Cookie string `gorm:"type:longtext" json:"-"`
|
||||
// RawPassword AES加密后的cpdaily明文密码(base64编码),用于cookie过期时自动重新登录
|
||||
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||
|
||||
// 今日校园真实姓名
|
||||
RealName string `gorm:"type:varchar(255)" json:"realName"`
|
||||
@@ -37,8 +37,12 @@ type SysUser struct {
|
||||
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
|
||||
// 可签到次数
|
||||
SignCount int `gorm:"type:int;default:0" json:"signCount"`
|
||||
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
|
||||
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
|
||||
// 邀请码
|
||||
InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"`
|
||||
// 邀请人用户ID(null=非邀请注册)
|
||||
InviterID *uint `gorm:"type:int;index" json:"inviterId"`
|
||||
// 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到)
|
||||
InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"`
|
||||
// 累计邀请人数
|
||||
@@ -52,6 +56,12 @@ type SysUser struct {
|
||||
// 最后登录时间
|
||||
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
|
||||
|
||||
// 最后登录 IP(真实登录或当天首次使用平台时记录;nginx 转发 X-Real-IP/X-Forwarded-For)
|
||||
LastLoginIP string `gorm:"type:varchar(64);default:''" json:"lastLoginIp"`
|
||||
|
||||
// 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导,跨设备生效)
|
||||
GuideVersion string `gorm:"type:varchar(32);default:''" json:"guideVersion"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
+453
-297
@@ -2,28 +2,145 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"cpdaily-new/internal/assets"
|
||||
"cpdaily-new/internal/config"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
var (
|
||||
captchaStore = sync.Map{}
|
||||
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
captchaStore = sync.Map{} // Redis 不可用时的兜底存储(进程级,带过期懒清理)
|
||||
)
|
||||
|
||||
const (
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaKeyFmt = "captcha:%s" // 验证码内容
|
||||
captchaRLKey = "captcha:rl:%s"
|
||||
|
||||
// 验证码四周统一留白(px):文字、干扰线、装饰全部限制在该留白区内
|
||||
captchaMargin = 15
|
||||
|
||||
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
|
||||
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
|
||||
captchaBucketCapacity = 10
|
||||
captchaBucketWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// 令牌桶 Lua 脚本(原子):状态存 "剩余tokens:上次补桶毫秒时间戳"
|
||||
// 返回 {1,0} 放行(已扣 1 个 token);{0, waitMs} 被限流(需等待 waitMs 毫秒后才有新 token)
|
||||
var captchaTokenScript = redis.NewScript(`
|
||||
local cap = tonumber(ARGV[1])
|
||||
local rateMs = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local windowMs = tonumber(ARGV[4])
|
||||
|
||||
local tokens = cap
|
||||
local last = now
|
||||
local data = redis.call('GET', KEYS[1])
|
||||
if data then
|
||||
local colon = string.find(data, ':')
|
||||
if colon then
|
||||
tokens = tonumber(string.sub(data, 1, colon - 1))
|
||||
last = tonumber(string.sub(data, colon + 1))
|
||||
if not tokens or not last then tokens = cap end
|
||||
end
|
||||
end
|
||||
|
||||
local elapsedMs = now - last
|
||||
if elapsedMs > 0 then
|
||||
tokens = tokens + elapsedMs * rateMs
|
||||
end
|
||||
if tokens > cap then tokens = cap end
|
||||
|
||||
if tokens < 1 then
|
||||
local waitMs = math.ceil((1 - tokens) / rateMs)
|
||||
if waitMs < 1000 then waitMs = 1000 end
|
||||
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
|
||||
return { 0, waitMs }
|
||||
end
|
||||
|
||||
tokens = tokens - 1
|
||||
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
|
||||
return { 1, 0 }
|
||||
`)
|
||||
|
||||
// redisCaptchaOp 短超时上下文(不阻塞请求)
|
||||
func redisCaptchaCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 2*time.Second)
|
||||
}
|
||||
|
||||
// storeCaptchaCode 存储验证码(优先 Redis,Redis 不可用时兜底内存)
|
||||
func storeCaptchaCode(captchaID, code string) error {
|
||||
if Redis != nil {
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
if err := Redis.Set(ctx, fmt.Sprintf(captchaKeyFmt, captchaID), code, captchaTTL).Err(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
slog.Warn("验证码写入 Redis 失败,降级内存存储", "err", err)
|
||||
}
|
||||
}
|
||||
captchaStore.Store(captchaID, CaptchaValue{
|
||||
Code: code,
|
||||
ExpiresAt: time.Now().Add(captchaTTL),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type CaptchaResult struct {
|
||||
CaptchaID string `json:"captchaId"`
|
||||
ImageBase64 string `json:"imageBase64"`
|
||||
}
|
||||
|
||||
// ───── 字体 ─────
|
||||
|
||||
var (
|
||||
captchaFontOnce sync.Once
|
||||
captchaFont *opentype.Font
|
||||
)
|
||||
|
||||
// loadCaptchaFont 进程内解析一次 Sour Gummy ExtraBold 字体(圆润软糖风格,OFL 开源授权)。
|
||||
// 字体经 go:embed 编译进二进制,解析失败属于构建产物损坏,直接 panic 快速暴露。
|
||||
func loadCaptchaFont() *opentype.Font {
|
||||
captchaFontOnce.Do(func() {
|
||||
f, err := opentype.Parse(assets.SourGummyDigits)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("验证码字体解析失败: %v", err))
|
||||
}
|
||||
captchaFont = f
|
||||
})
|
||||
return captchaFont
|
||||
}
|
||||
|
||||
// captchaFace 按指定字号创建字体 face
|
||||
func captchaFace(size float64) font.Face {
|
||||
face, err := opentype.NewFace(loadCaptchaFont(), &opentype.FaceOptions{
|
||||
Size: size,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("创建验证码字体 face 失败: %v", err))
|
||||
}
|
||||
return face
|
||||
}
|
||||
|
||||
// 二次元风格配色 — 亮色系
|
||||
var animeColors = []color.RGBA{
|
||||
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
|
||||
@@ -50,115 +167,128 @@ var pastelBG = []color.RGBA{
|
||||
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
|
||||
}
|
||||
|
||||
// softCol 生成半透明直线 alpha 颜色(color.NRGBA)。
|
||||
// 必须用 NRGBA 而非给 RGBA 强改 A:image.RGBA 是预乘格式,通道值大于 alpha 属于非法
|
||||
// 预乘值,PNG 编码时按 alpha 除回归会溢出截断,产生黑块/杂色。
|
||||
func softCol(c color.RGBA, a uint8) color.NRGBA {
|
||||
return color.NRGBA{R: c.R, G: c.G, B: c.B, A: a}
|
||||
}
|
||||
|
||||
// 干扰线专用色池:避开红/粉暖色系(用户反馈干扰线太红),取冷色+金色
|
||||
var lineColors = []color.RGBA{
|
||||
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
|
||||
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
|
||||
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
|
||||
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
|
||||
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
|
||||
}
|
||||
|
||||
// 生成随机验证码
|
||||
func generateCode(length int) string {
|
||||
digits := "0123456789"
|
||||
var sb strings.Builder
|
||||
for i := 0; i < length; i++ {
|
||||
sb.WriteByte(digits[rng.Intn(len(digits))])
|
||||
sb.WriteByte(digits[rand.Intn(len(digits))])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 生成图片验证码
|
||||
// 生成图片验证码(可爱风:Sour Gummy 圆润数字 + 白描边贴纸字 + 马卡龙背景 + 弯曲斜线干扰线 + 糖珠点缀)
|
||||
func GenerateCaptcha() (*CaptchaResult, error) {
|
||||
cfg := config.Global.Captcha
|
||||
code := generateCode(cfg.Length)
|
||||
|
||||
// 生成图片
|
||||
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
|
||||
// 画布最小尺寸保障:config.toml 服务器侧独立维护、可能未同步新尺寸,
|
||||
// 低于该值时 15px 四周留白会挤占内容区,代码层强制放大
|
||||
w, h := cfg.Width, cfg.Height
|
||||
if w < 180 {
|
||||
w = 180
|
||||
}
|
||||
if h < 60 {
|
||||
h = 60
|
||||
}
|
||||
|
||||
// 随机马卡龙色背景
|
||||
bg := pastelBG[rng.Intn(len(pastelBG))]
|
||||
// 生成图片
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
// 随机马卡龙色背景(铺满整图)
|
||||
bg := pastelBG[rand.Intn(len(pastelBG))]
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||
|
||||
// ── 干扰线(粗线,高不透明度)──
|
||||
for i := 0; i < 10; i++ {
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = uint8(rng.Intn(80) + 150) // alpha 150~230
|
||||
// ── 四周统一留白 15px:所有内容元素均限制在留白区内 ──
|
||||
m := captchaMargin
|
||||
ix0, iy0 := m, m
|
||||
ix1, iy1 := w-m, h-m
|
||||
boxW, boxH := ix1-ix0, iy1-iy0
|
||||
|
||||
x1 := rng.Intn(cfg.Width)
|
||||
y1 := rng.Intn(cfg.Height)
|
||||
x2 := rng.Intn(cfg.Width)
|
||||
y2 := rng.Intn(cfg.Height)
|
||||
|
||||
thick := rng.Intn(2) + 2 // 2~3px 粗
|
||||
drawThickLine(img, x1, y1, x2, y2, col, thick)
|
||||
// ── 背景装饰:白色半透明大圆(软糖气泡感)──
|
||||
for i := 0; i < 2; i++ {
|
||||
col := softCol(color.RGBA{255, 255, 255, 255}, uint8(rand.Intn(14)+18))
|
||||
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(12)+14, col)
|
||||
}
|
||||
|
||||
// ── 二次元风格彩色折线 ──
|
||||
for i := 0; i < 6; i++ {
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = uint8(rng.Intn(60) + 130)
|
||||
|
||||
x1 := rng.Intn(cfg.Width)
|
||||
y1 := rng.Intn(cfg.Height)
|
||||
xm := rng.Intn(cfg.Width)
|
||||
ym := rng.Intn(cfg.Height)
|
||||
x2 := rng.Intn(cfg.Width)
|
||||
y2 := rng.Intn(cfg.Height)
|
||||
drawThickLine(img, x1, y1, xm, ym, col, 2)
|
||||
drawThickLine(img, xm, ym, x2, y2, col, 2)
|
||||
// ── 糖珠点缀:实心小圆 + 空心泡泡 ──
|
||||
for i := 0; i < 5; i++ {
|
||||
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(60)+90))
|
||||
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(2)+1, col)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(40)+70))
|
||||
drawRing(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(3)+3, col)
|
||||
}
|
||||
|
||||
// ── 装饰圆圈(半透明彩色气泡)──
|
||||
for i := 0; i < 4; i++ {
|
||||
cx := rng.Intn(cfg.Width)
|
||||
cy := rng.Intn(cfg.Height)
|
||||
radius := rng.Intn(5) + 3
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = 40
|
||||
drawCircle(img, cx, cy, radius, col)
|
||||
// ── 字符:Sour Gummy 圆润字体渲染数字,白描边贴纸风 + 随机旋转 ±10° ──
|
||||
// 数字实际字高约 0.65em,字号取内容高的 1.25 倍让字符饱满贴近留白线;
|
||||
// 并以每字符可用宽度约束上限
|
||||
fontSize := float64(boxH) * 1.25
|
||||
if per := float64(boxW) / float64(cfg.Length) / 0.6 * 1.1; fontSize > per {
|
||||
fontSize = per
|
||||
}
|
||||
face := captchaFace(fontSize)
|
||||
defer face.Close()
|
||||
|
||||
for i, c := range code {
|
||||
centerX := ix0 + boxW*(2*i+1)/(2*cfg.Length)
|
||||
centerY := iy0 + boxH/2
|
||||
col := animeColors[rand.Intn(len(animeColors))]
|
||||
angle := rand.Float64()*20 - 10 // -10° ~ +10°
|
||||
drawStickerChar(img, face, centerX, centerY, string(c), col, angle)
|
||||
}
|
||||
|
||||
// 小星星/闪光装饰点
|
||||
for i := 0; i < 6; i++ {
|
||||
x := rng.Intn(cfg.Width)
|
||||
y := rng.Intn(cfg.Height)
|
||||
starCol := animeColors[rng.Intn(len(animeColors))]
|
||||
starCol.A = 100
|
||||
drawStarDot(img, x, y, starCol)
|
||||
// 少量柔和彩色噪点(半透明小点)
|
||||
for i := 0; i < 20; i++ {
|
||||
x := ix0 + rand.Intn(boxW)
|
||||
y := iy0 + rand.Intn(boxH)
|
||||
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(50)+40))
|
||||
setPx(img, x, y, col)
|
||||
}
|
||||
|
||||
// 彩色噪点
|
||||
for i := 0; i < 100; i++ {
|
||||
x := rng.Intn(cfg.Width)
|
||||
y := rng.Intn(cfg.Height)
|
||||
col := animeColors[rng.Intn(len(animeColors))]
|
||||
col.A = uint8(rng.Intn(100) + 80)
|
||||
img.Set(x, y, col)
|
||||
}
|
||||
|
||||
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
|
||||
charBlockW := cfg.Width / cfg.Length
|
||||
charPixelSize := 4 // 4px 像素块
|
||||
charMapW := 7 // 字符矩阵宽度
|
||||
charMapH := 9 // 字符矩阵高度
|
||||
charW := charMapW * charPixelSize
|
||||
charH := charMapH * charPixelSize
|
||||
|
||||
for i, ch := range code {
|
||||
// 每个字符在其块中居中
|
||||
blockCenterX := i*charBlockW + charBlockW/2
|
||||
charX := blockCenterX - charW/2
|
||||
charY := (cfg.Height - charH) / 2
|
||||
|
||||
// 微调位置
|
||||
charX += rng.Intn(5) - 2
|
||||
charY += rng.Intn(3) - 1
|
||||
|
||||
// 每个字符选择鲜艳的二次元颜色
|
||||
charColor := animeColors[rng.Intn(len(animeColors))]
|
||||
|
||||
// 随机倾斜程度 (shear: 每行偏移像素数)
|
||||
shear := rng.Intn(2) + 1 // 1~2
|
||||
|
||||
// 1/3 概率向右倾斜,2/3 向左倾斜
|
||||
if rng.Intn(3) != 0 {
|
||||
shear = -shear
|
||||
// ── 干扰线:贯穿式弯曲斜线(左右端随机高度 + 强制大倾角 + 四向随机,
|
||||
// 线条本身带 1~2 个正弦拱——单拱似抛物线,双拱似波浪;绘制在数字上层)──
|
||||
for i := 0; i < 3; i++ {
|
||||
col := softCol(lineColors[rand.Intn(len(lineColors))], uint8(rand.Intn(40)+60))
|
||||
y1 := iy0 + rand.Intn(boxH+1)
|
||||
y2 := iy0 + rand.Intn(boxH+1)
|
||||
// 保证“很斜”:两端高度差至少内容高的一半;差值方向即上斜/下斜随机
|
||||
if dy := y2 - y1; abs(dy) < boxH/2 {
|
||||
step := boxH/2 + rand.Intn(boxH/4+1)
|
||||
if dy < 0 {
|
||||
y2 = y1 - step // 右端更低 → 左高右低(下斜)
|
||||
if y2 < iy0 {
|
||||
y2, y1 = iy0, min(iy0+step, iy1)
|
||||
}
|
||||
} else {
|
||||
y2 = y1 + step // 右端更高 → 左低右高(上斜)
|
||||
if y2 > iy1 {
|
||||
y2, y1 = iy1, max(iy1-step, iy0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
|
||||
// 随机交换起止端点,覆盖左斜/右斜方向
|
||||
if rand.Intn(2) == 0 {
|
||||
y1, y2 = y2, y1
|
||||
}
|
||||
drawCurvedLine(img, ix0, y1, ix1, y2, col, 2)
|
||||
}
|
||||
|
||||
// 编码为 PNG base64
|
||||
@@ -170,10 +300,9 @@ func GenerateCaptcha() (*CaptchaResult, error) {
|
||||
|
||||
// 生成唯一 ID 并存储验证码
|
||||
captchaID := generateCode(16)
|
||||
captchaStore.Store(captchaID, CaptchaValue{
|
||||
Code: code,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
if err := storeCaptchaCode(captchaID, code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CaptchaResult{
|
||||
CaptchaID: captchaID,
|
||||
@@ -190,6 +319,19 @@ func VerifyCaptcha(captchaID, code string) bool {
|
||||
if captchaID == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
// 优先 Redis:GETDEL 原子取出并删除(Redis 6.2+)
|
||||
if Redis != nil {
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
val, err := Redis.GetDel(ctx, fmt.Sprintf(captchaKeyFmt, captchaID)).Result()
|
||||
if err == nil {
|
||||
return strings.EqualFold(val, code)
|
||||
}
|
||||
if err != redis.Nil {
|
||||
slog.Warn("验证码校验读取 Redis 失败", "err", err)
|
||||
}
|
||||
// 未命中或出错:再查内存兜底
|
||||
}
|
||||
val, ok := captchaStore.Load(captchaID)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -202,59 +344,232 @@ func VerifyCaptcha(captchaID, code string) bool {
|
||||
return strings.EqualFold(cv.Code, code)
|
||||
}
|
||||
|
||||
// GetCaptchaDebug 读取验证码(调试用)
|
||||
func GetCaptchaDebug(captchaID string) (string, bool) {
|
||||
val, ok := captchaStore.Load(captchaID)
|
||||
if !ok {
|
||||
return "", false
|
||||
// AllowCaptchaToken 验证码生成令牌桶限流:同 IP 5 分钟内最多获取 10 次。
|
||||
// 返回 (true, 0) 表示允许生成并已扣减 token;
|
||||
// 返回 (false, wait) 表示被限流,wait 为最快可再次获取的等待时长(已向上取整到秒)。
|
||||
// 空 IP 或 Redis 不可用/出错时放行(存储兜底同样受限,避免误伤正常用户)。
|
||||
func AllowCaptchaToken(ip string) (bool, time.Duration) {
|
||||
if ip == "" || Redis == nil {
|
||||
return true, 0
|
||||
}
|
||||
cv := val.(CaptchaValue)
|
||||
return cv.Code, true
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
|
||||
rateMs := float64(captchaBucketCapacity) / float64(captchaBucketWindow.Milliseconds())
|
||||
res, err := captchaTokenScript.Run(ctx, Redis,
|
||||
[]string{fmt.Sprintf(captchaRLKey, ip)},
|
||||
captchaBucketCapacity,
|
||||
fmt.Sprintf("%.12f", rateMs),
|
||||
time.Now().UnixMilli(),
|
||||
captchaBucketWindow.Milliseconds(),
|
||||
).Int64Slice()
|
||||
if err != nil {
|
||||
slog.Warn("验证码限流 Redis 操作失败,放行", "err", err)
|
||||
return true, 0
|
||||
}
|
||||
if len(res) != 2 {
|
||||
return true, 0
|
||||
}
|
||||
if res[0] == 1 {
|
||||
return true, 0
|
||||
}
|
||||
// waitMs 向上取整到秒(至少 1 秒)
|
||||
wait := time.Duration(((res[1]+999)/1000)*1000) * time.Millisecond
|
||||
if wait < time.Second {
|
||||
wait = time.Second
|
||||
}
|
||||
return false, wait
|
||||
}
|
||||
|
||||
// FormatWait 将等待时长格式化为提示文案片段:如 "5 秒"、"3 分钟"
|
||||
func FormatWait(d time.Duration) string {
|
||||
secs := int((d + 999*time.Millisecond) / time.Second)
|
||||
switch {
|
||||
case secs < 60:
|
||||
return fmt.Sprintf("%d 秒", secs)
|
||||
case secs < 3600:
|
||||
return fmt.Sprintf("%d 分钟", (secs+59)/60)
|
||||
default:
|
||||
return fmt.Sprintf("%d 小时", (secs+3599)/3600)
|
||||
}
|
||||
}
|
||||
|
||||
// setPx 带边界检查的安全画点
|
||||
func setPx(img *image.RGBA, x, y int, col color.Color) {
|
||||
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
||||
img.Set(x, y, col)
|
||||
}
|
||||
}
|
||||
|
||||
// drawCurvedLine 绘制带弧度的贯穿斜线:沿 (x1,y1)→(x2,y2) 直线走向,
|
||||
// 按线段法向叠加 1~2 个正弦拱偏移(单拱似抛物线、双拱似波浪),拱向随机,
|
||||
// 起止点仍在直线两端——整体保持大倾角四向随机,线条本身带柔和小波浪。
|
||||
func drawCurvedLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
steps := max(abs(dx), abs(dy))
|
||||
if steps == 0 {
|
||||
return
|
||||
}
|
||||
lineLen := math.Hypot(float64(dx), float64(dy))
|
||||
amp := 3 + rand.Intn(4) // 拱高 3~6px
|
||||
arches := 1 + rand.Intn(2) // 1~2 个拱
|
||||
phase := rand.Float64() * 2 * math.Pi
|
||||
flip := 1.0
|
||||
if rand.Intn(2) == 0 {
|
||||
flip = -1.0 // 拱起方向随机(法向正/负)
|
||||
}
|
||||
nx, ny := float64(-dy)/lineLen, float64(dx)/lineLen // 单位法向量
|
||||
for i := 0; i <= steps; i++ {
|
||||
t := float64(i) / float64(steps)
|
||||
cx := float64(x1) + float64(dx)*t
|
||||
cy := float64(y1) + float64(dy)*t
|
||||
off := flip * float64(amp) * math.Sin(float64(arches)*2*math.Pi*t+phase)
|
||||
px := int(cx + nx*off + 0.5)
|
||||
py := int(cy + ny*off + 0.5)
|
||||
for k := 0; k < thickness; k++ {
|
||||
setPx(img, px, py+k-thickness/2, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drawRing 绘制空心圆环(泡泡装饰)
|
||||
func drawRing(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||
for step := 0; step < 72; step++ {
|
||||
a := float64(step) * math.Pi / 36
|
||||
x := cx + int(float64(radius)*math.Cos(a)+0.5)
|
||||
y := cy + int(float64(radius)*math.Sin(a)+0.5)
|
||||
setPx(img, x, y, col)
|
||||
}
|
||||
}
|
||||
|
||||
// strokeDirs 白色描边的 12 方向偏移(半径 2px 圆周均匀采样,叠出圆润粗描边)
|
||||
var strokeDirs []image.Point
|
||||
|
||||
func init() {
|
||||
for i := 0; i < 12; i++ {
|
||||
a := float64(i) * math.Pi / 6
|
||||
strokeDirs = append(strokeDirs, image.Point{
|
||||
int(math.Round(2 * math.Cos(a))),
|
||||
int(math.Round(2 * math.Sin(a))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// drawStickerChar 以 (cx,cy) 为中心绘制“贴纸风”字符:
|
||||
// Sour Gummy 圆润字形 + 12 方向白色粗描边(贴纸效果)+ 糖果色填充,
|
||||
// 再按 angleDeg 旋转后以 alpha 混合贴到底图。
|
||||
// 按字形实际包围盒(而非 ascent/descent 行高)在临时画布中精确居中——
|
||||
// 数字无下降部,若按行高定位会整体偏上、下方留白偏大。
|
||||
func drawStickerChar(dst *image.RGBA, face font.Face, cx, cy int, char string, col color.RGBA, angleDeg float64) {
|
||||
// 测量字形包围盒(相对绘制原点 Dot 的 26.6 定点坐标)
|
||||
gb, _, _, _, ok := face.Glyph(fixed.Point26_6{}, []rune(char)[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
glyphW := gb.Dx()
|
||||
glyphH := gb.Dy()
|
||||
|
||||
pad := 4 // 描边 + 旋转余量
|
||||
tmpW, tmpH := glyphW+pad*2, glyphH+pad*2
|
||||
tmp := image.NewRGBA(image.Rect(0, 0, tmpW, tmpH))
|
||||
|
||||
// 平移 Dot 使字形包围盒左上角恰好落在 (pad,pad),即字形在画布中精确居中
|
||||
baseDX := fixed.I(pad - gb.Min.X)
|
||||
baseDY := fixed.I(pad - gb.Min.Y)
|
||||
|
||||
// 白色描边:12 方向偏移叠加
|
||||
white := image.NewUniform(color.RGBA{255, 255, 255, 255})
|
||||
for _, off := range strokeDirs {
|
||||
fd := &font.Drawer{
|
||||
Dst: tmp, Src: white, Face: face,
|
||||
Dot: fixed.Point26_6{X: baseDX + fixed.I(off.X), Y: baseDY + fixed.I(off.Y)},
|
||||
}
|
||||
fd.DrawString(char)
|
||||
}
|
||||
// 糖果色主体
|
||||
fd := &font.Drawer{
|
||||
Dst: tmp, Src: image.NewUniform(col), Face: face,
|
||||
Dot: fixed.Point26_6{X: baseDX, Y: baseDY},
|
||||
}
|
||||
fd.DrawString(char)
|
||||
|
||||
rotateOverlay(dst, tmp, cx, cy, angleDeg)
|
||||
}
|
||||
|
||||
// rotateOverlay 将透明背景的 src 以 (cx,cy) 为中心旋转 angleDeg 后混合到 dst。
|
||||
// 反向映射 + 双线性采样:1:1 旋转下无空洞、边缘平滑无锯齿。
|
||||
func rotateOverlay(dst, src *image.RGBA, cx, cy int, angleDeg float64) {
|
||||
a := angleDeg * math.Pi / 180
|
||||
cos, sin := math.Cos(a), math.Sin(a)
|
||||
srcCx := float64(src.Bounds().Dx()) / 2
|
||||
srcCy := float64(src.Bounds().Dy()) / 2
|
||||
|
||||
radius := int(math.Hypot(srcCx, srcCy)) + 1
|
||||
db := dst.Bounds()
|
||||
for y := cy - radius; y <= cy+radius; y++ {
|
||||
if y < db.Min.Y || y >= db.Max.Y {
|
||||
continue
|
||||
}
|
||||
for x := cx - radius; x <= cx+radius; x++ {
|
||||
if x < db.Min.X || x >= db.Max.X {
|
||||
continue
|
||||
}
|
||||
dx, dy := float64(x-cx), float64(y-cy)
|
||||
sx := cos*dx + sin*dy + srcCx
|
||||
sy := -sin*dx + cos*dy + srcCy
|
||||
sr, sg, sb, sa := sampleBilinear(src, sx, sy)
|
||||
if sa <= 0 {
|
||||
continue
|
||||
}
|
||||
// 预乘 alpha 域的 over 混合:out = src + dst*(1-α)
|
||||
af := sa / 255
|
||||
dr, dg, dbb, da := dst.At(x, y).RGBA()
|
||||
nr := sr + float64(dr>>8)*(1-af)
|
||||
ng := sg + float64(dg>>8)*(1-af)
|
||||
nb := sb + float64(dbb>>8)*(1-af)
|
||||
na := sa + float64(da>>8)*(1-af)
|
||||
dst.Set(x, y, color.RGBA{uint8(nr), uint8(ng), uint8(nb), uint8(na)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sampleBilinear 对预乘 alpha 的 RGBA 图做双线性采样,返回 0-255 范围的预乘 RGBA
|
||||
func sampleBilinear(src *image.RGBA, sx, sy float64) (r, g, b, a float64) {
|
||||
bnd := src.Bounds()
|
||||
x0, y0 := int(math.Floor(sx)), int(math.Floor(sy))
|
||||
fx, fy := sx-float64(x0), sy-float64(y0)
|
||||
if sx < 0 || sy < 0 || sx >= float64(bnd.Max.X) || sy >= float64(bnd.Max.Y) {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
for dy := 0; dy <= 1; dy++ {
|
||||
for dx := 0; dx <= 1; dx++ {
|
||||
px, py := x0+dx, y0+dy
|
||||
if px < bnd.Min.X || px >= bnd.Max.X || py < bnd.Min.Y || py >= bnd.Max.Y {
|
||||
continue
|
||||
}
|
||||
wx := 1 - fx
|
||||
if dx == 1 {
|
||||
wx = fx
|
||||
}
|
||||
wy := 1 - fy
|
||||
if dy == 1 {
|
||||
wy = fy
|
||||
}
|
||||
w := wx * wy
|
||||
if w == 0 {
|
||||
continue
|
||||
}
|
||||
c := src.RGBAAt(px, py)
|
||||
r += float64(c.R) * w
|
||||
g += float64(c.G) * w
|
||||
b += float64(c.B) * w
|
||||
a += float64(c.A) * w
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 画粗线(绘制多条平行线模拟粗线)
|
||||
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
steps := max(abs(dx), abs(dy))
|
||||
if steps == 0 {
|
||||
return
|
||||
}
|
||||
for i := 0; i <= steps; i++ {
|
||||
cx := x1 + dx*i/steps
|
||||
cy := y1 + dy*i/steps
|
||||
// 在垂直方向偏移绘制粗线效果
|
||||
for t := -(thickness - 1); t < thickness; t++ {
|
||||
// 根据线的方向决定偏移方向
|
||||
var px, py int
|
||||
if abs(dx) > abs(dy) {
|
||||
px = cx
|
||||
py = cy + t
|
||||
} else {
|
||||
px = cx + t
|
||||
py = cy
|
||||
}
|
||||
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
||||
img.Set(px, py, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 划线(单像素)
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
|
||||
dx, dy := x2-x1, y2-y1
|
||||
steps := max(abs(dx), abs(dy))
|
||||
if steps == 0 {
|
||||
return
|
||||
}
|
||||
for i := 0; i <= steps; i++ {
|
||||
x := x1 + dx*i/steps
|
||||
y := y1 + dy*i/steps
|
||||
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
||||
img.Set(x, y, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制半透明圆圈
|
||||
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||
@@ -271,165 +586,6 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制小星星闪光点
|
||||
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
|
||||
offsets := [][2]int{
|
||||
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
||||
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
|
||||
}
|
||||
for _, off := range offsets {
|
||||
px := x + off[0]
|
||||
py := y + off[1]
|
||||
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
||||
img.Set(px, py, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
|
||||
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
|
||||
// 7x9 数字点阵映射
|
||||
charMaps := map[string][][]int{
|
||||
"0": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"1": {
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
{0, 1, 1, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 0, 0, 1, 0, 0, 0},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"2": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"3": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"4": {
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 1, 1, 1, 0},
|
||||
{0, 0, 1, 1, 1, 1, 0},
|
||||
{0, 1, 1, 0, 1, 1, 0},
|
||||
{1, 1, 0, 0, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
},
|
||||
"5": {
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"6": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 0, 0, 0, 0, 0},
|
||||
{1, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"7": {
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 1, 1, 0},
|
||||
{0, 0, 0, 1, 1, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
{0, 0, 1, 1, 0, 0, 0},
|
||||
},
|
||||
"8": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
"9": {
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{1, 1, 0, 0, 0, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{0, 0, 0, 0, 0, 1, 1},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
{0, 1, 1, 1, 1, 1, 0},
|
||||
},
|
||||
}
|
||||
|
||||
pattern, ok := charMaps[char]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// shear: 每行相对中心行(4)的水平偏移
|
||||
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
|
||||
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
|
||||
for py, row := range pattern {
|
||||
// 行偏移量 = (py - 4) * shear
|
||||
rowShear := (py - 4) * shear
|
||||
|
||||
for px, v := range row {
|
||||
if v == 1 {
|
||||
ix := x + px*pixelSize + rowShear
|
||||
iy := y + py*pixelSize
|
||||
for dx := 0; dx < pixelSize; dx++ {
|
||||
for dy := 0; dy < pixelSize; dy++ {
|
||||
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
|
||||
img.Set(ix+dx, iy+dy, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// simpleCookieJar 简化的 CookieJar 实现
|
||||
@@ -29,6 +30,20 @@ func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
cookieMap[c.Name] = c
|
||||
}
|
||||
for _, c := range cookies {
|
||||
// 删除指令(Max-Age<=0 或 Expires 已过)或空值:一律跳过,既不入库也不删除
|
||||
// 已有同名 cookie。simpleCookieJar 不记录 Path/Domain,无法按 RFC 6265 精确
|
||||
// 匹配删除指令的作用域。云大 CAS 登录 POST 成功后先发
|
||||
// CASTGC=TGT-...; Path=/authserver
|
||||
// 再发删除指令
|
||||
// CASTGC=; Max-Age=0; path=/authserver/ (注意尾部斜杠,与 TGT 的 Path 不同)
|
||||
// 浏览器依 RFC 6265 不会删除 TGT,后续 MFA 页面(reAuthLoginView.do)依赖该
|
||||
// TGT 判定 SSO 会话;若按名字粗暴删除 TGT,MFA 页会被 302 踢回登录页。
|
||||
// 若把空值 CASTGC 存进 jar 发送空值,同样会被判定会话异常(历史 bug)。
|
||||
// 跳过删除指令可同时避免这两种问题;同名残留会在下次登录时被 CAS 重新
|
||||
// Set-Cookie 覆盖,无实际副作用。
|
||||
if c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) || c.Value == "" {
|
||||
continue
|
||||
}
|
||||
cookieMap[c.Name] = c
|
||||
}
|
||||
|
||||
@@ -67,3 +82,18 @@ func (j *simpleCookieJar) AllCookies() []*http.Cookie {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AllCookiesByHost 按域名分组返回所有 cookie(MFA 会话暂存用,保留域结构以便恢复)
|
||||
func (j *simpleCookieJar) AllCookiesByHost() map[string][]*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
result := make(map[string][]*http.Cookie, len(j.cookies))
|
||||
for host, cookies := range j.cookies {
|
||||
if len(cookies) > 0 {
|
||||
cp := make([]*http.Cookie, len(cookies))
|
||||
copy(cp, cookies)
|
||||
result[host] = cp
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package cpdaily
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
@@ -14,11 +17,12 @@ import (
|
||||
|
||||
// CpImageUploadPolicy 今日校园 OBS 上传策略
|
||||
type CpImageUploadPolicy struct {
|
||||
AccessId string `json:"accessid"`
|
||||
FileName string `json:"fileName"`
|
||||
Policy string `json:"policy"`
|
||||
Host string `json:"host"`
|
||||
Signature string `json:"signature"`
|
||||
AccessId string `json:"accessid"`
|
||||
FileName string `json:"fileName"`
|
||||
Policy string `json:"policy"`
|
||||
Host string `json:"host"`
|
||||
Signature string `json:"signature"`
|
||||
FormData map[string]string `json:"formData"`
|
||||
}
|
||||
|
||||
// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀
|
||||
@@ -26,6 +30,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:
|
||||
@@ -75,7 +82,11 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
return result.Datas, nil
|
||||
}
|
||||
|
||||
// uploadToOBS 将文件上传到今日校园的 OBS
|
||||
// uploadToOBS 将文件上传到今日校园的 OBS。
|
||||
// 优先使用接口返回的 formData 字段集原样提交:不同任务类型的表单字段不同
|
||||
// (查寝:AccessKeyId/signature/x-obs-acl;签到:q-ak/q-key-time/q-signature 等),
|
||||
// 按旧方式自行拼装字段会导致签到类型上传被 OBS 拒绝(AccessDenied)。
|
||||
// 仅当接口未返回 formData 时回退到通用字段拼装。
|
||||
func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error {
|
||||
client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
|
||||
@@ -87,15 +98,26 @@ func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType strin
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
writer.WriteField("x-obs-acl", "public-read")
|
||||
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||
writer.WriteField("key", policy.FileName)
|
||||
writer.WriteField("signature", policy.Signature)
|
||||
writer.WriteField("policy", policy.Policy)
|
||||
writer.WriteField("content-type", contentType)
|
||||
if len(policy.FormData) > 0 {
|
||||
for k, v := range policy.FormData {
|
||||
writer.WriteField(k, v)
|
||||
}
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="file"; filename="file.jpg"`)
|
||||
header.Set("Content-Type", contentType)
|
||||
part, _ := writer.CreatePart(header)
|
||||
part.Write(fileData)
|
||||
} else {
|
||||
writer.WriteField("x-obs-acl", "public-read")
|
||||
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||
writer.WriteField("key", policy.FileName)
|
||||
writer.WriteField("signature", policy.Signature)
|
||||
writer.WriteField("policy", policy.Policy)
|
||||
writer.WriteField("content-type", contentType)
|
||||
|
||||
part, _ := writer.CreateFormFile("file", "file")
|
||||
part.Write(fileData)
|
||||
part, _ := writer.CreateFormFile("file", "file")
|
||||
part.Write(fileData)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
resp, err := client.R().
|
||||
@@ -123,6 +145,9 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
switch taskType {
|
||||
case TaskTypeSleepCheck:
|
||||
apiPath = "/previewAttachment"
|
||||
case TaskTypeLeave:
|
||||
// 销假图片预览走请假模块 OBS 接口
|
||||
apiPath = "/obs/previewAttachment"
|
||||
case TaskTypeSign:
|
||||
apiPath = "/sign/previewAttachment"
|
||||
default:
|
||||
@@ -145,15 +170,33 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
var result struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w", err)
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
// 部分学校(如 cyprxy 代理域)返回 {"code":"0",...} 方言
|
||||
if len(result.Code) > 0 {
|
||||
var codeStr string
|
||||
if json.Unmarshal(result.Code, &codeStr) == nil {
|
||||
if codeStr != "" && codeStr != "0" {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = result.ErrMsg
|
||||
}
|
||||
return "", fmt.Errorf("获取预览 URL 错误(%s): %s", codeStr, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result.Datas) == 0 || string(result.Datas) == "null" {
|
||||
return "", fmt.Errorf("获取预览 URL 为空(body: %.200s)", string(body))
|
||||
}
|
||||
|
||||
var previewUrl string
|
||||
if err := json.Unmarshal(result.Datas, &previewUrl); err != nil {
|
||||
@@ -164,6 +207,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
}
|
||||
|
||||
// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL
|
||||
// 预览接口偶发返回空响应,做少量重试避免误判失败
|
||||
func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) {
|
||||
policy, err := GetUploadPolicy(cookieMap, host, taskType)
|
||||
if err != nil {
|
||||
@@ -174,17 +218,47 @@ func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fi
|
||||
return "", err
|
||||
}
|
||||
|
||||
previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var previewUrl string
|
||||
var lastErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(time.Duration(i) * time.Second)
|
||||
}
|
||||
previewUrl, lastErr = GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||
if lastErr == nil {
|
||||
return previewUrl, nil
|
||||
}
|
||||
// 业务性错误(错误码/数据为空)无需重试,仅空响应类瞬时错误重试
|
||||
if !strings.Contains(lastErr.Error(), "解析预览 URL 响应失败") && !strings.Contains(lastErr.Error(), "获取预览 URL 为空") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return previewUrl, nil
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// IsDataURIImage 判断内容是否为 base64 图片(前端本地预览,尚未成功上传到 OBS)
|
||||
func IsDataURIImage(content string) bool {
|
||||
return strings.HasPrefix(content, "data:image")
|
||||
}
|
||||
|
||||
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
|
||||
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
|
||||
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
|
||||
idx := strings.Index(dataURI, ",")
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("图片数据格式不正确")
|
||||
}
|
||||
imgBytes, err := base64.StdEncoding.DecodeString(dataURI[idx+1:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
return UploadImage(cookieMap, host, taskType, imgBytes, "image/jpeg")
|
||||
}
|
||||
|
||||
func newImgClient() *resty.Client {
|
||||
return resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetTimeout(30 * time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
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=已完成,已过滤)
|
||||
// 学校规定的销假位置(详情 reportPlaces)。有值时前端展示可选列表并预填经纬度;
|
||||
// 无值时提示用户手动填写
|
||||
ReportPlaces []LeaveReportPlace `json:"reportPlaces"`
|
||||
// 销假是否需要选择审批人(leaveType.reportApplyFlow=1)
|
||||
ReportApplyFlow bool `json:"reportApplyFlow"`
|
||||
// 销假是否允许上传图片(leaveType.enableAttach=1)
|
||||
EnableAttach bool `json:"enableAttach"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
|
||||
func leaveAnyStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// leaveAnyBool 将 interface{} 值转为布尔(兼容数字/字符串/布尔三种返回风格)
|
||||
func leaveAnyBool(v interface{}) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case float64:
|
||||
return t != 0
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return n != 0
|
||||
case string:
|
||||
return t == "1" || strings.EqualFold(t, "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// leaveAnyInt 将 interface{} 值转为整数(兼容数字/字符串两种返回风格)
|
||||
func leaveAnyInt(v interface{}) int {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(t))
|
||||
return n
|
||||
case bool:
|
||||
if t {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// leaveMapStr 从 map 中取字符串值
|
||||
func leaveMapStr(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return leaveAnyStr(m[key])
|
||||
}
|
||||
|
||||
// leaveTimeWithSeconds 将 "2025-10-24 20:45" 补秒为审批流接口要求的 "2025-10-24 20:45:00"
|
||||
func leaveTimeWithSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 16 { // yyyy-MM-dd HH:mm
|
||||
return t + ":00"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// leavePost 通用 POST 请求(带移动端 UA 与 Cookie),返回响应 body;
|
||||
// extension 非空时额外携带 Cpdaily-Extension 签名头(销假提交用)
|
||||
func leavePost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
req := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap))
|
||||
if extension != "" {
|
||||
req = req.SetHeader("Cpdaily-Extension", extension)
|
||||
}
|
||||
resp, err := req.SetBody(body).Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
return io.ReadAll(resp.RawBody())
|
||||
}
|
||||
|
||||
// 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 := json.Unmarshal(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")
|
||||
if status == "4" { // 已完成,无需销假
|
||||
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,
|
||||
}
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
|
||||
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 := json.Unmarshal(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),
|
||||
}
|
||||
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 := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveApprover, 0, len(result.Datas.Approvers))
|
||||
seen := make(map[string]bool)
|
||||
for _, a := range result.Datas.Approvers {
|
||||
if a.UserWid == "" || seen[a.UserWid] {
|
||||
continue
|
||||
}
|
||||
seen[a.UserWid] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
for _, n := range result.Datas.ApproveNodes {
|
||||
for _, node := range n.ApproveNode {
|
||||
for _, a := range node.Approvers {
|
||||
if a.UserWid == "" || seen[a.UserWid] {
|
||||
continue
|
||||
}
|
||||
seen[a.UserWid] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// BuildLeaveReportForm 构造销假提交表单(bodyString 内容)。
|
||||
// reportLocation 为用户选择/填写的销假地址,为空时兜底第一个规定销假位置地址;
|
||||
// approverWid 为所选审批人(reportApplyFlow=1 时必填,其余传空)。
|
||||
func BuildLeaveReportForm(d *LeaveDetail, address, imageUrl, approverWid string) map[string]interface{} {
|
||||
if address == "" && len(d.ReportPlaces) > 0 {
|
||||
address = d.ReportPlaces[0].Address
|
||||
}
|
||||
form := map[string]interface{}{
|
||||
"id": d.ReportID,
|
||||
"needApproval": 1,
|
||||
"leaveType": d.LeaveCode,
|
||||
"reportLocation": address,
|
||||
"approverWid": approverWid,
|
||||
"reportApplyExplain": nil,
|
||||
"uaIsCpadaily": true,
|
||||
}
|
||||
if imageUrl != "" {
|
||||
form["reportAttach"] = []map[string]interface{}{
|
||||
{"type": 1, "url": imageUrl},
|
||||
}
|
||||
} else {
|
||||
form["reportAttach"] = []interface{}{}
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveReport 提交销假(bodyString 加密 + Cpdaily-Extension 签名,与签到提交同构)
|
||||
func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
bodyString := GetBodyString(form)
|
||||
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||
|
||||
submitData := map[string]interface{}{
|
||||
"lon": lon,
|
||||
"version": "first_v4",
|
||||
"calVersion": "first_v4",
|
||||
"deviceId": deviceId,
|
||||
"systemName": "android",
|
||||
"bodyString": bodyString,
|
||||
"lat": lat,
|
||||
"systemVersion": "15",
|
||||
"appVersion": "9.9.6",
|
||||
"model": "cpdaily",
|
||||
}
|
||||
submitData["userId"] = username
|
||||
submitData["sign"] = GetSubmitSign(submitData)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/report", submitData, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交销假请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(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 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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"` // 类型名称
|
||||
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=不限)
|
||||
}
|
||||
|
||||
// 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 := json.Unmarshal(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 := json.Unmarshal(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 := json.Unmarshal(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 // 定位地址
|
||||
ImageURL string // 附件图片 URL(可选)
|
||||
ApproverWid string // 审批人 userWid(needApproval=1 时必填)
|
||||
ApproverId string // 审批人 userId
|
||||
ApproverName string // 审批人姓名
|
||||
}
|
||||
|
||||
// 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": map[string]interface{}{
|
||||
"teachers": []interface{}{},
|
||||
"students": []interface{}{},
|
||||
},
|
||||
"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 p.ImageURL != "" {
|
||||
form["applyAttach"] = []map[string]interface{}{
|
||||
{"type": 1, "url": p.ImageURL},
|
||||
}
|
||||
} 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 := json.Unmarshal(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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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: "福建省厦门市思明区",
|
||||
ImageURL: "https://obs.example.com/a.jpg",
|
||||
ApproverWid: "93458825",
|
||||
ApproverId: "20220109",
|
||||
ApproverName: "罗亭",
|
||||
}
|
||||
form := BuildLeaveApplyForm(p, "device-1", "15", "9.9.6")
|
||||
|
||||
if form["leaveType"] != 3 || form["startTime"] != "2026-09-15 08:00:00" || form["outStatus"] != true {
|
||||
t.Errorf("基础字段错误: %+v", form)
|
||||
}
|
||||
if form["destination"] != "福建省/厦门市/思明区/某某路 1 号" {
|
||||
t.Errorf("destination 拼接错误: %v", form["destination"])
|
||||
}
|
||||
if form["approverWid"] != "93458825" || form["approverId"] != "20220109" || form["approverName"] != "罗亭" {
|
||||
t.Errorf("审批人字段错误: %v/%v/%v", form["approverWid"], form["approverId"], form["approverName"])
|
||||
}
|
||||
attach, ok := form["applyAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/a.jpg" {
|
||||
t.Errorf("附件结构错误: %v", form["applyAttach"])
|
||||
}
|
||||
loc, ok := form["location"].(map[string]interface{})
|
||||
if !ok || loc["longitude"] != "118.1" || loc["latitude"] != "24.6" || loc["address"] != "福建省厦门市思明区" {
|
||||
t.Errorf("location 结构错误: %v", form["location"])
|
||||
}
|
||||
|
||||
// 不离校:紧急联系人/地址字段清空、destination 为空、无审批人/无附件
|
||||
p2 := LeaveApplyParams{
|
||||
LeaveType: 1,
|
||||
StartTime: "2026-09-15 08:00:00",
|
||||
EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息",
|
||||
OutStatus: false,
|
||||
UrgencyMobile: "13800000000", // 故意传入,应被清空
|
||||
Address: "福建省/厦门市",
|
||||
DetailAddress: "某某路",
|
||||
Longitude: "118.1",
|
||||
Latitude: "24.6",
|
||||
LocationAddr: "校内",
|
||||
}
|
||||
form2 := BuildLeaveApplyForm(p2, "device-1", "15", "9.9.6")
|
||||
if form2["urgencyMobile"] != "" || form2["address"] != "" || form2["detailAddress"] != "" {
|
||||
t.Errorf("不离校时地址字段应清空: %v/%v/%v", form2["urgencyMobile"], form2["address"], form2["detailAddress"])
|
||||
}
|
||||
if form2["destination"] != "" {
|
||||
t.Errorf("不离校时 destination 应为空: %v", form2["destination"])
|
||||
}
|
||||
if _, ok := form2["approverWid"]; ok {
|
||||
t.Errorf("未选审批人时不应携带 approverWid")
|
||||
}
|
||||
if attach, ok := form2["applyAttach"].([]interface{}); !ok || len(attach) != 0 {
|
||||
t.Errorf("无附件时 applyAttach 应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitLeaveApply(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
}, "device-1", "15", "9.9.6")
|
||||
|
||||
if err := SubmitLeaveApply(map[string]string{}, srv.URL, form, "118.1", "24.6", "device-1", "20251960205"); err != nil {
|
||||
t.Errorf("SubmitLeaveApply 应成功: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,23 @@ func newSessionClient() *sessionClient {
|
||||
}).
|
||||
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")
|
||||
client.SetCookieJar(jar)
|
||||
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
|
||||
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
|
||||
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
|
||||
// "go-resty/2.x"(见 resty redirect.go checkHostAndAddHeaders),而 wisedu 定制 CAS
|
||||
// 按请求 UA 渲染移动端/PC 模板。云南大学等学校从 campusphere 网关跨域跳到 CAS 时
|
||||
// UA 被替换,CAS 渲染 PC 模板导致后续 MFA 页面(reAuthLoginView.do)校验失败被
|
||||
// 302 踢回登录页,无法解析 reAuthType。保留 UA 后移动端模板正常渲染。
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 20 {
|
||||
return fmt.Errorf("stopped after %d redirects", 20)
|
||||
}
|
||||
if len(via) > 0 && !strings.EqualFold(getHostnameForRedirect(req.URL.Host), getHostnameForRedirect(via[0].URL.Host)) {
|
||||
if ua := via[0].Header.Get("User-Agent"); ua != "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
return &sessionClient{
|
||||
Client: client,
|
||||
jar: jar,
|
||||
@@ -52,7 +67,15 @@ func readBody(resp *resty.Response) []byte {
|
||||
return resp.Body()
|
||||
}
|
||||
|
||||
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
// getHostnameForRedirect 去掉端口后返回小写主机名(重定向跨域判断用)
|
||||
func getHostnameForRedirect(host string) string {
|
||||
if i := strings.Index(host, ":"); i > 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 获取合法的登录 URL
|
||||
@@ -65,7 +88,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
return nil, nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
}
|
||||
|
||||
loginPageBytes := readBody(loginPageResp)
|
||||
@@ -83,13 +106,25 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
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
|
||||
if isSliderCaptchaSchool(school.ID) {
|
||||
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
|
||||
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
|
||||
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
|
||||
// 通过后 captcha 字段留空提交
|
||||
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 {
|
||||
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||
switch loginType {
|
||||
case 0:
|
||||
allInputs["captchaResponse"] = captchaCode
|
||||
default:
|
||||
allInputs["captcha"] = captchaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +138,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
if loginResp != nil && loginResp.StatusCode() == 302 {
|
||||
slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
||||
} else {
|
||||
return nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +147,20 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
// 7. 处理响应
|
||||
switch loginResp.StatusCode() {
|
||||
case 302:
|
||||
slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location"))
|
||||
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
location := loginResp.Header().Get("Location")
|
||||
// 多因子认证:302 到 reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=...
|
||||
// 此时 CAS 会话有效但未签发票据,需自动发送验证码并等待用户提交
|
||||
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
|
||||
slog.Info("CAS触发多因子认证(MFA)", "location", location)
|
||||
mfa, err := handleMFATriggered(ctx, loginResp, school, sc)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, mfa, ErrMFARequired
|
||||
}
|
||||
slog.Info("CAS登录成功", "redirect", location)
|
||||
cookies, err := handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
return cookies, nil, err
|
||||
case 200, 401:
|
||||
body := readBody(loginResp)
|
||||
slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)]))
|
||||
@@ -121,15 +168,16 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
if err == nil && isCasNeedKickOut(doc) {
|
||||
slog.Info("CAS需要踢下线")
|
||||
kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client)
|
||||
return handleCasLoginSuccess(ctx, kickResp, school, sc)
|
||||
cookies, err := handleCasLoginSuccess(ctx, kickResp, school, sc)
|
||||
return cookies, nil, err
|
||||
}
|
||||
msg := getCasErrorMessage(doc)
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查账号密码"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +188,14 @@ func getCasValidLoginUrl(school *SchoolDetailInfo) string {
|
||||
return school.IdsUrl
|
||||
}
|
||||
}
|
||||
// 优先选择 campusphere / wec 网关入口:登录后 cookie 落在网关域,
|
||||
// 后续签到/查寝等任务请求(wec-counselor-* 均在网关域)才能携带有效会话。
|
||||
// 部分学校 AmpUrl 为学校自研门户(如 ehall),若登录到该域,cookie 与任务接口域不匹配。
|
||||
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||
if u != "" && (strings.Contains(u, "campusphere") || strings.Contains(u, "wec-portal-mobile")) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||
if u != "" {
|
||||
return u
|
||||
@@ -156,8 +212,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)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 合肥师范学院专用登录适配 ==========
|
||||
//
|
||||
// 该校(hfnu.campusphere.net)登录走 portal.hfnu.edu.cn 老式 jasig CAS
|
||||
// (/sso/login,webflow lt/e1sX 风格),与通用 CAS 流程存在两处不兼容,
|
||||
// 故单独适配:
|
||||
// 1. 会话用 URL 重写传递(form action 形如 /sso/login;jsessionid=xxx?service=...),
|
||||
// 服务器不通过 Set-Cookie 建立 session,POST 必须发到 form 的 action
|
||||
// 地址(带 ;jsessionid),否则 webflow 会话丢失、静默回到登录页;
|
||||
// 2. 每次登录都要求图形验证码(页面 JS: codeupdate==0 → 强制显示
|
||||
// #code_validate),验证码图片 /sso/code/code.jsp,4 位纯数字 JPEG,
|
||||
// 提交字段为 validateCode。通用流程探测的 /authserver/needCaptcha.html
|
||||
// 在该校返回空 body,会被误判为"不需要验证码"。
|
||||
//
|
||||
// 密码为明文提交(页面无加密 salt),登录成功后 302 回跳
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess?sessionToken=...&ticket=...
|
||||
|
||||
// HfnuSchoolId 合肥师范学院 tenant id
|
||||
const HfnuSchoolId = "hfnu"
|
||||
|
||||
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
|
||||
|
||||
// hfnuLogin 合肥师范学院专用登录
|
||||
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
slog.Info("合肥师范专用登录", "loginUrl", loginUrl)
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. GET 登录页(跟随重定向到 portal.hfnu.edu.cn/sso/login)
|
||||
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||
}
|
||||
pageBody := readBody(loginPageResp)
|
||||
pageURL := loginPageResp.RawResponse.Request.URL
|
||||
slog.Info("合肥师范登录页", "finalUrl", pageURL.String(), "bodyLen", len(pageBody))
|
||||
|
||||
// 2. 解析 form action(含 ;jsessionid)与 lt
|
||||
postUrl, lt, err := parseHfnuLoginForm(pageBody, pageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("合肥师范CAS表单", "postUrl", postUrl, "lt", lt)
|
||||
|
||||
// 3. 获取验证码并 OCR(该校每次登录都需要)
|
||||
captchaCode, err := resolveHfnuCaptcha(ctx, pageURL, postUrl, sc.Client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 提交登录(禁用重定向,登录成功返回 302)
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().
|
||||
SetContext(loginCtx).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"validateCode": captchaCode,
|
||||
"lt": lt,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
if err != nil {
|
||||
if loginResp != nil && loginResp.StatusCode() == http.StatusFound {
|
||||
slog.Info("合肥师范登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
||||
} else {
|
||||
return nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("合肥师范登录响应", "statusCode", loginResp.StatusCode())
|
||||
switch loginResp.StatusCode() {
|
||||
case http.StatusFound:
|
||||
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||
case http.StatusOK, http.StatusUnauthorized:
|
||||
// 2026-09 排查:该校认证流程本身可用(账密/验证码校验均可通过),
|
||||
// 但 CAS 认证通过后校方 amp-auth-adapter 单点跳转服务拒绝授权
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
msg := parseHfnuLoginError(readBody(loginResp))
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
}
|
||||
|
||||
// parseHfnuLoginForm 从登录页解析 form action(绝对化,保留 ;jsessionid)与 lt
|
||||
func parseHfnuLoginForm(page []byte, pageURL *url.URL) (string, string, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("解析登录页失败: %w", err)
|
||||
}
|
||||
form := doc.Find("#credentials")
|
||||
if form.Length() == 0 {
|
||||
form = doc.Find("form").First()
|
||||
}
|
||||
if form.Length() == 0 {
|
||||
return "", "", fmt.Errorf("登录页未找到登录表单")
|
||||
}
|
||||
|
||||
action, _ := form.Attr("action")
|
||||
postUrl := ""
|
||||
if action != "" {
|
||||
if u, err := pageURL.Parse(action); err == nil {
|
||||
postUrl = u.String()
|
||||
}
|
||||
}
|
||||
if postUrl == "" {
|
||||
return "", "", fmt.Errorf("登录页未找到表单提交地址")
|
||||
}
|
||||
|
||||
lt := ""
|
||||
form.Find("input").Each(func(_ int, s *goquery.Selection) {
|
||||
name, _ := s.Attr("name")
|
||||
if name == "lt" {
|
||||
lt, _ = s.Attr("value")
|
||||
}
|
||||
})
|
||||
if lt == "" {
|
||||
return "", "", fmt.Errorf("登录页未找到 lt 参数")
|
||||
}
|
||||
return postUrl, lt, nil
|
||||
}
|
||||
|
||||
// resolveHfnuCaptcha 拉取 /sso/code/code.jsp 验证码并 OCR。
|
||||
// 该校 session 走 URL 重写,验证码请求需带上与 form action 相同的 ;jsessionid。
|
||||
func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, client *resty.Client) (string, error) {
|
||||
// 登录页路径为 /sso/login;相对路径 code/code.jsp 基于其解析 → /sso/code/code.jsp
|
||||
captchaUrl, err := pageURL.Parse("code/code.jsp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析验证码地址失败: %w", err)
|
||||
}
|
||||
// 该校不依赖 Cookie 而用 URL 重写:把 form action 中的 jsessionid 拼到验证码路径
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
captchaUrl.Path = captchaUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
captchaUrl.RawQuery = fmt.Sprintf("t=%.3d", time.Now().UnixNano()%1000)
|
||||
slog.Info("合肥师范验证码", "url", captchaUrl.String())
|
||||
|
||||
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取验证码失败: %w", err)
|
||||
}
|
||||
imgData := readBody(imgResp)
|
||||
if len(imgData) == 0 {
|
||||
return "", fmt.Errorf("验证码图片为空")
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
code := captchaOCR(imgData)
|
||||
if code == "" {
|
||||
return "", fmt.Errorf("验证码识别失败,请重试")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// parseHfnuLoginError 解析登录失败页的错误提示
|
||||
func parseHfnuLoginError(body []byte) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
|
||||
if err == nil {
|
||||
if msg := strings.TrimSpace(doc.Find("li.err").Text()); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS 多因子认证(MFA / reAuth)==========
|
||||
|
||||
// 今日校园定制 CAS 的多因子认证扩展(reAuthCheck)。
|
||||
// 触发场景:CAS 账密提交成功后不是直接签发 ST 票据,而是 302 到
|
||||
// {base}/authserver/reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=<encoded>
|
||||
// 该页面渲染当前用户全部可用的二次认证方式(Tab / 更多菜单 / 旧版列表),
|
||||
// 调用方应把方式列表返回给用户选择;用户选定后调用发码接口(按需先 changeReAuthType.do
|
||||
// 切换服务端会话方式),用户取得验证码后再提交 reAuthSubmit.do 完成认证,
|
||||
// 最后访问 /login?service= 完成票据交换。
|
||||
|
||||
// ErrMFARequired 表示本次登录触发了多因子认证,
|
||||
// 调用方应为返回的 MFAContext 暂存并引导用户选择验证方式、获取验证码并提交。
|
||||
var ErrMFARequired = fmt.Errorf("mfa_required")
|
||||
|
||||
// ErrMFASessionExpired 表示 MFA 中间会话已失效(Redis 过期 / reAuth_unauthorized / 会话被服务端作废),
|
||||
// 调用方应引导用户重新登录(可用 errors.Is 判断)。
|
||||
var ErrMFASessionExpired = fmt.Errorf("mfa_session_expired")
|
||||
|
||||
// MFAAuthMethod 一种可用的多因子验证方式(来自 MFA 页面渲染的可用方式列表)
|
||||
type MFAAuthMethod struct {
|
||||
Type string `json:"type"` // reAuthType 数字编码(3=短信 4=企业微信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号)
|
||||
Name string `json:"name"` // 展示名(优先页面文案,缺省用内置映射)
|
||||
Default bool `json:"default,omitempty"` // 页面默认选中项
|
||||
}
|
||||
|
||||
// MFAContext 触发 MFA 后的中间会话状态(可 JSON 序列化存 Redis,TTL 内有效)
|
||||
type MFAContext struct {
|
||||
BaseURL string `json:"baseUrl"` // CAS 根地址,如 http://ids.hfpec.edu.cn
|
||||
ContextPath string `json:"contextPath"` // CAS 上下文路径,默认 /authserver
|
||||
Service string `json:"service"` // reAuth service(含 sessionToken)
|
||||
ReAuthType string `json:"reAuthType"` // 当前/用户最后选定的方式:2=密码 3=短信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号
|
||||
UserID string `json:"userId"` // 学工号
|
||||
AuthMethods []MFAAuthMethod `json:"authMethods,omitempty"` // 页面支持的全部可用验证方式
|
||||
WecloudUrl string `json:"wecloudUrl"` // 完成登录后 activateSession 所需
|
||||
CookiesByHost map[string][]*http.Cookie `json:"cookiesByHost"` // CAS 阶段会话(按域保存)
|
||||
}
|
||||
|
||||
// MFASendResult 发送 MFA 验证码的结果
|
||||
type MFASendResult struct {
|
||||
Mobile string `json:"mobile,omitempty"` // 收码目标(手机号等,可能带掩码)
|
||||
CodeTime int `json:"codeTime,omitempty"` // 可再次发送的倒计时秒数(0 表示不限制/未提供)
|
||||
Message string `json:"message,omitempty"` // 服务端提示文案
|
||||
}
|
||||
|
||||
// reAuthType → 发码接口的 authCodeTypeName 映射(源自 mobile-reAuth.js sendDynamicCodeByPhone)
|
||||
var reAuthCodeTypeNames = map[string]string{
|
||||
"3": "reAuthDynamicCodeType", // 手机短信
|
||||
"4": "reAuthWChatDynamicCodeType", // 企业微信
|
||||
"5": "reAuthCpdailyDynamicCodeType", // 今日校园
|
||||
"11": "reAuthEmailDynamicCodeType", // 邮箱
|
||||
"12": "reAuthDingTalkDynamicCodeType", // 钉钉
|
||||
"13": "reAuthWeLinkDynamicCodeType", // WeLink
|
||||
"15": "reAuthWeChatServiceDynamicCodeType", // 微信公众号
|
||||
}
|
||||
|
||||
// reAuthType → 默认展示名(页面 Tab 无文案时兜底)
|
||||
var reAuthTypeNames = map[string]string{
|
||||
"2": "密码验证",
|
||||
"3": "短信验证码",
|
||||
"4": "企业微信验证码",
|
||||
"5": "今日校园验证码",
|
||||
"6": "实人认证",
|
||||
"7": "安全问题",
|
||||
"9": "QQ扫码",
|
||||
"10": "安全令牌OTP",
|
||||
"11": "邮箱验证",
|
||||
"12": "钉钉验证码",
|
||||
"13": "WeLink验证码",
|
||||
"15": "公众号验证码",
|
||||
}
|
||||
|
||||
// 需要发送动态码的 reAuthType 集合(密码/人脸/密保问题等类型不走发码接口)
|
||||
func reAuthTypeNeedsDynamicCode(reAuthType string) bool {
|
||||
_, ok := reAuthCodeTypeNames[reAuthType]
|
||||
return ok
|
||||
}
|
||||
|
||||
var (
|
||||
reAuthParamsBlockRegexp = regexp.MustCompile(`var\s+reAuthParams\s*=\s*\{([\s\S]*?)\n\s*\}`)
|
||||
reAuthTypeRegexp = regexp.MustCompile(`"reAuthType"\s*:\s*"([^"]*)"`)
|
||||
reAuthServiceRegexp = regexp.MustCompile(`"service"\s*:\s*"([^"]*)"`)
|
||||
reAuthUserIdRegexp = regexp.MustCompile(`"reAuthUserId"\s*:\s*"([^"]*)"`)
|
||||
contextPathRegexp = regexp.MustCompile(`contextPath\s*=\s*"([^"]*)"`)
|
||||
trailingDigitsRegexp = regexp.MustCompile(`(\d+)$`)
|
||||
)
|
||||
|
||||
// 页面可用验证方式选择器(兼容历代模板;注释块内的元素不会被 goquery 匹配):
|
||||
// .mobile-reauth-tab-item[data-type] 移动端 Tab(如 id="mobile_tab_5")
|
||||
// .reauth-tab-item[data-type] 新版统一模板 Tab(如 id="tab_13")
|
||||
// .reauth-tab-more-item[id] "更多"菜单(id 即编号)
|
||||
// .changeReAuthTypes[id] 旧版列表(id 即编号)
|
||||
// .other_type_button[id] "选择认证方式"弹窗按钮(如许昌学院 xcuTheme,id 即编号)
|
||||
const mfaMethodSelectors = ".mobile-reauth-tab-item, .reauth-tab-item, .reauth-tab-more-item, .changeReAuthTypes, .other_type_button"
|
||||
|
||||
// parseMFAAuthMethods 从 MFA 页面解析全部可用验证方式(返回页面渲染顺序)
|
||||
func parseMFAAuthMethods(page string) []MFAAuthMethod {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(page))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var methods []MFAAuthMethod
|
||||
seen := make(map[string]bool)
|
||||
doc.Find(mfaMethodSelectors).Each(func(_ int, s *goquery.Selection) {
|
||||
reAuthType := strings.TrimSpace(s.AttrOr("data-type", ""))
|
||||
if reAuthType == "" {
|
||||
// 无 data-type 时从 id 提取尾部数字:mobile_tab_5 / tab_13 / 5 均可
|
||||
reAuthType = trailingDigitsRegexp.FindString(s.AttrOr("id", ""))
|
||||
}
|
||||
if reAuthType == "" || seen[reAuthType] {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(s.Find(".mobile-reauth-tab-text").Text())
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.Text())
|
||||
}
|
||||
if name == "" {
|
||||
name = reAuthTypeNames[reAuthType]
|
||||
}
|
||||
if name == "" {
|
||||
name = "验证方式" + reAuthType
|
||||
}
|
||||
seen[reAuthType] = true
|
||||
methods = append(methods, MFAAuthMethod{
|
||||
Type: reAuthType,
|
||||
Name: name,
|
||||
Default: s.HasClass("mobile-reauth-tab-active") || s.HasClass("reauth-tab-active"),
|
||||
})
|
||||
})
|
||||
return methods
|
||||
}
|
||||
|
||||
// normalizeMFAAuthMethods 规范化可用验证方式列表:
|
||||
// 1. 仅保留能接收动态码、本流程可完成的方式(短信/企业微信/今日校园/邮箱/钉钉/WeLink/公众号);
|
||||
// 2. 过滤后为空时保留原列表(页面异常时前端仍能看到可选方式,发码时会得到明确提示);
|
||||
// 3. 列表整体为空(页面无 Tab / 解析失败)且当前方式支持动态码时,用当前方式兜底为唯一选项,
|
||||
// 避免前端「选择验证方式」无项可选、流程走不通;
|
||||
// 4. 页面未标记默认项时,按 defaultType 标记,最后兜底标记首项。
|
||||
func normalizeMFAAuthMethods(methods []MFAAuthMethod, defaultType string) []MFAAuthMethod {
|
||||
var supported []MFAAuthMethod
|
||||
for _, m := range methods {
|
||||
if reAuthTypeNeedsDynamicCode(m.Type) {
|
||||
supported = append(supported, m)
|
||||
}
|
||||
}
|
||||
if len(supported) > 0 {
|
||||
methods = supported
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
if !reAuthTypeNeedsDynamicCode(defaultType) {
|
||||
return methods
|
||||
}
|
||||
// 页面无 Tab(解析失败或旧版模板):用当前 reAuthType 兜底为唯一方式
|
||||
name := reAuthTypeNames[defaultType]
|
||||
if name == "" {
|
||||
name = "验证方式" + defaultType
|
||||
}
|
||||
return []MFAAuthMethod{{Type: defaultType, Name: name, Default: true}}
|
||||
}
|
||||
if !hasDefaultMFAMethod(methods) {
|
||||
for i := range methods {
|
||||
if methods[i].Type == defaultType {
|
||||
methods[i].Default = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasDefaultMFAMethod(methods) {
|
||||
methods[0].Default = true
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// hasDefaultMFAMethod 列表内是否已有默认项
|
||||
func hasDefaultMFAMethod(methods []MFAAuthMethod) bool {
|
||||
for _, m := range methods {
|
||||
if m.Default {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleMFATriggered 处理 CAS 登录后 302 到 MFA 页面的情况:
|
||||
// 解析 MFA 页面参数与全部可用验证方式,返回 MFAContext 供用户选择方式、发送并提交验证码。
|
||||
func handleMFATriggered(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) (*MFAContext, error) {
|
||||
location, err := resp.RawResponse.Location()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取MFA重定向地址失败: %w", err)
|
||||
}
|
||||
|
||||
// 恢复自动重定向(后续请求页面需要跟随)
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(location.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求MFA页面失败: %w", err)
|
||||
}
|
||||
page := string(readBody(pageResp))
|
||||
slog.Info("MFA页面获取", "url", location.String(), "bodyLen", len(page))
|
||||
|
||||
mfa := &MFAContext{
|
||||
WecloudUrl: school.WecloudUrl,
|
||||
}
|
||||
|
||||
// CAS 根地址与上下文路径
|
||||
if u, err := url.Parse(location.String()); err == nil {
|
||||
mfa.BaseURL = u.Scheme + "://" + u.Host
|
||||
}
|
||||
mfa.ContextPath = "/authserver"
|
||||
if m := contextPathRegexp.FindStringSubmatch(page); len(m) > 1 {
|
||||
mfa.ContextPath = strings.ReplaceAll(m[1], `\/`, "/")
|
||||
}
|
||||
|
||||
// 解析 reAuthParams 块内的关键字段
|
||||
block := reAuthParamsBlockRegexp.FindStringSubmatch(page)
|
||||
if len(block) > 1 {
|
||||
params := block[1]
|
||||
if m := reAuthTypeRegexp.FindStringSubmatch(params); len(m) > 1 {
|
||||
mfa.ReAuthType = m[1]
|
||||
}
|
||||
if m := reAuthServiceRegexp.FindStringSubmatch(params); len(m) > 1 {
|
||||
mfa.Service = strings.ReplaceAll(m[1], `\/`, "/")
|
||||
}
|
||||
if m := reAuthUserIdRegexp.FindStringSubmatch(params); len(m) > 1 {
|
||||
mfa.UserID = m[1]
|
||||
}
|
||||
}
|
||||
// 兜底:service 从 302 Location 的 query 取
|
||||
if mfa.Service == "" {
|
||||
mfa.Service = location.Query().Get("service")
|
||||
}
|
||||
if mfa.ReAuthType == "" {
|
||||
mfa.ReAuthType = "5"
|
||||
slog.Warn("MFA页面未解析到reAuthType,按动态码处理", "base", mfa.BaseURL)
|
||||
}
|
||||
|
||||
// 解析页面全部可用验证方式(Tab / 更多菜单 / 旧版列表)
|
||||
mfa.AuthMethods = normalizeMFAAuthMethods(parseMFAAuthMethods(page), mfa.ReAuthType)
|
||||
slog.Info("MFA参数解析", "reAuthType", mfa.ReAuthType, "userId", mfa.UserID,
|
||||
"service", mfa.Service, "methods", len(mfa.AuthMethods))
|
||||
|
||||
// 不再自动发送验证码:由用户在前端选择验证方式后调用 SendMFADynamicCode 发送
|
||||
if !reAuthTypeNeedsDynamicCode(mfa.ReAuthType) {
|
||||
slog.Warn("MFA默认验证方式非动态码类型,需用户选择其他方式", "reAuthType", mfa.ReAuthType)
|
||||
}
|
||||
|
||||
// 保存当前 CAS 会话(按域)
|
||||
mfa.CookiesByHost = sc.jar.AllCookiesByHost()
|
||||
|
||||
return mfa, nil
|
||||
}
|
||||
|
||||
// restoreMFACasSession 把触发 MFA 时保存的 CAS 会话恢复到新的会话客户端
|
||||
func restoreMFACasSession(sc *sessionClient, mfa *MFAContext) {
|
||||
for host, cookies := range mfa.CookiesByHost {
|
||||
u := &url.URL{Scheme: "http", Host: host}
|
||||
if strings.HasPrefix(mfa.BaseURL, "https://") {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
sc.jar.SetCookies(u, cookies)
|
||||
}
|
||||
}
|
||||
|
||||
// changeMFAAuthType 调用 changeReAuthType.do 把服务端 MFA 会话切换到指定验证方式
|
||||
// POST {base}{contextPath}/reAuthCheck/changeReAuthType.do
|
||||
func changeMFAAuthType(ctx context.Context, client *resty.Client, mfa *MFAContext, reAuthType string) error {
|
||||
changeUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/changeReAuthType.do"
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"isMultifactor": "true",
|
||||
"reAuthType": reAuthType,
|
||||
"service": mfa.Service,
|
||||
}).
|
||||
Post(changeUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("切换MFA验证方式失败: %w", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
slog.Info("MFA切换验证方式响应", "status", resp.StatusCode(), "reAuthType", reAuthType,
|
||||
"body", string(body[:min(len(body), 500)]))
|
||||
|
||||
// code 兼容数字 1 与字符串 "1"(不同学校版本返回类型不一致)
|
||||
var result struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("切换MFA验证方式响应解析失败: %w", err)
|
||||
}
|
||||
if fmt.Sprintf("%v", result.Code) != "1" {
|
||||
return fmt.Errorf("%s", orDefault(result.Message, "该验证方式暂不可用,请选择其他方式"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendReAuthDynamicCodeResult 发码接口响应
|
||||
type sendReAuthDynamicCodeResult struct {
|
||||
Res string `json:"res"` // success/cpdaily_success/wechat_success/other_success/code_time_fail
|
||||
ReturnMessage string `json:"returnMessage"` // 服务端提示文案
|
||||
Mobile string `json:"mobile"` // 收码目标(手机号等)
|
||||
CodeTime int `json:"codeTime"` // 可再次发送的倒计时秒数
|
||||
ErrCode string `json:"errCode"` // 错误码(如 206302 会话失效)
|
||||
}
|
||||
|
||||
// postReAuthDynamicCode 调用 CAS 发码接口发送验证码
|
||||
// POST {base}{contextPath}/dynamicCode/getDynamicCodeByReauth.do
|
||||
func postReAuthDynamicCode(ctx context.Context, client *resty.Client, mfa *MFAContext, authCodeTypeName string) (*sendReAuthDynamicCodeResult, error) {
|
||||
sendUrl := mfa.BaseURL + mfa.ContextPath + "/dynamicCode/getDynamicCodeByReauth.do"
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"userName": mfa.UserID,
|
||||
"authCodeTypeName": authCodeTypeName,
|
||||
}).
|
||||
Post(sendUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送MFA验证码失败: %w", err)
|
||||
}
|
||||
body := readBody(resp)
|
||||
slog.Info("MFA验证码发送响应", "status", resp.StatusCode(), "body", string(body[:min(len(body), 500)]))
|
||||
|
||||
var result sendReAuthDynamicCodeResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("发送MFA验证码响应解析失败: %w", err)
|
||||
}
|
||||
// 206302:服务端 MFA 会话已失效
|
||||
if result.ErrCode == "206302" {
|
||||
return nil, fmt.Errorf("%w: 验证会话已失效,请重新登录", ErrMFASessionExpired)
|
||||
}
|
||||
|
||||
switch result.Res {
|
||||
case "success", "cpdaily_success", "wechat_success", "other_success", "code_time_fail":
|
||||
// 发送成功(code_time_fail 为倒计时未结束,验证码此前已发出)
|
||||
return &result, nil
|
||||
default:
|
||||
msg := result.ReturnMessage
|
||||
if msg == "" {
|
||||
msg = "发送验证码失败,请稍后重试"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMFADynamicCode 用户选定验证方式后发送动态验证码:
|
||||
// 与当前方式不同时先调用 changeReAuthType.do 切换服务端会话,再调用发码接口;
|
||||
// 成功后同步 mfa.ReAuthType,供后续 reAuthSubmit.do 提交使用。
|
||||
func SendMFADynamicCode(ctx context.Context, mfa *MFAContext, reAuthType string) (*MFASendResult, error) {
|
||||
if mfa == nil || len(mfa.CookiesByHost) == 0 {
|
||||
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
|
||||
}
|
||||
if reAuthType == "" {
|
||||
reAuthType = mfa.ReAuthType
|
||||
}
|
||||
if len(mfa.AuthMethods) > 0 && !mfaHasAuthMethod(mfa.AuthMethods, reAuthType) {
|
||||
return nil, fmt.Errorf("不支持的验证方式,请选择其他方式")
|
||||
}
|
||||
if !reAuthTypeNeedsDynamicCode(reAuthType) {
|
||||
name := reAuthTypeNames[reAuthType]
|
||||
if name == "" {
|
||||
name = "验证方式" + reAuthType
|
||||
}
|
||||
return nil, fmt.Errorf("「%s」暂不支持发送验证码,请选择其他方式", name)
|
||||
}
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sc := newSessionClient()
|
||||
restoreMFACasSession(sc, mfa)
|
||||
|
||||
// 与当前会话方式不同时先切换(与官方页面 Tab 切换行为一致)
|
||||
if reAuthType != mfa.ReAuthType {
|
||||
if err := changeMFAAuthType(loginCtx, sc.Client, mfa, reAuthType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfa.ReAuthType = reAuthType
|
||||
slog.Info("MFA验证方式已切换", "reAuthType", reAuthType)
|
||||
}
|
||||
|
||||
result, err := postReAuthDynamicCode(loginCtx, sc.Client, mfa, reAuthCodeTypeNames[reAuthType])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MFASendResult{
|
||||
Mobile: result.Mobile,
|
||||
CodeTime: result.CodeTime,
|
||||
Message: orDefault(result.ReturnMessage, "验证码已发送"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mfaHasAuthMethod 判断方式编号是否在可用列表中
|
||||
func mfaHasAuthMethod(methods []MFAAuthMethod, reAuthType string) bool {
|
||||
for _, m := range methods {
|
||||
if m.Type == reAuthType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CompleteMFALogin 提交用户输入的验证码,完成 MFA 认证并走完 CAS 票据交换,
|
||||
// 返回登录成功后的全部 cookies。
|
||||
func CompleteMFALogin(ctx context.Context, mfa *MFAContext, code string) ([]*http.Cookie, error) {
|
||||
if mfa == nil || len(mfa.CookiesByHost) == 0 {
|
||||
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
|
||||
}
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sc := newSessionClient()
|
||||
// 恢复 CAS 阶段会话
|
||||
restoreMFACasSession(sc, mfa)
|
||||
|
||||
// 1. 提交验证码(reAuthSubmit.do 返回 JSON,不涉及重定向)
|
||||
submitUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/reAuthSubmit.do"
|
||||
submitResp, err := sc.R().SetContext(loginCtx).
|
||||
SetFormData(map[string]string{
|
||||
"service": mfa.Service,
|
||||
"reAuthType": mfa.ReAuthType,
|
||||
"isMultifactor": "true",
|
||||
"password": "",
|
||||
"dynamicCode": code,
|
||||
"uuid": "",
|
||||
"answer1": "",
|
||||
"answer2": "",
|
||||
"otpCode": "",
|
||||
}).
|
||||
Post(submitUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("提交MFA验证码失败: %w", err)
|
||||
}
|
||||
body := readBody(submitResp)
|
||||
slog.Info("MFA验证码提交响应", "status", submitResp.StatusCode(), "body", string(body[:min(len(body), 500)]))
|
||||
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
switch result.Code {
|
||||
case "reAuth_failed":
|
||||
return nil, fmt.Errorf("%s", orDefault(result.Msg, "验证码错误或已过期"))
|
||||
case "reAuth_unauthorized":
|
||||
return nil, fmt.Errorf("%w: %s", ErrMFASessionExpired, orDefault(result.Msg, "验证会话已失效,请重新登录"))
|
||||
}
|
||||
|
||||
// 2. 完成 CAS 票据交换:GET /login?service=... → 302(service?ticket=) → wecloud /iap/loginSuccess
|
||||
exchangeUrl := mfa.BaseURL + mfa.ContextPath + "/login?service=" + url.QueryEscape(mfa.Service)
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
exchangeResp, err := sc.R().SetContext(loginCtx).Get(exchangeUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MFA票据交换失败: %w", err)
|
||||
}
|
||||
_ = exchangeResp
|
||||
|
||||
// 3. 激活 wecloud 会话
|
||||
activateSession(loginCtx, mfa.WecloudUrl, sc.Client)
|
||||
|
||||
cookies := sc.jar.AllCookies()
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// orDefault 空串时取默认值
|
||||
func orDefault(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ========== MFA 页面「可用验证方式」解析测试 ==========
|
||||
|
||||
// 移动端模板样本(结构取自真实 MFA 页面,已脱敏)
|
||||
const mfaMobilePageSample = `<!DOCTYPE html><html><body>
|
||||
<script>var reAuthParams = {
|
||||
"reAuthType":"5",
|
||||
"isMultifactor":"true",
|
||||
"service":"https:\/\/school.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
|
||||
"reAuthUserId":"20220000000"
|
||||
};</script>
|
||||
<div class="mobile-reauth-tab-list" id="mobileTabList">
|
||||
<div class="mobile-reauth-tab-item mobile-reauth-tab-active" id="mobile_tab_5"
|
||||
onclick="handleMobileTabClick(this)" data-type="5" data-index="0">
|
||||
<span class="mobile-reauth-tab-text" title="今日校园验证码">今日校园验证码</span>
|
||||
</div>
|
||||
<div class="mobile-reauth-tab-item" id="mobile_tab_11" onclick="handleMobileTabClick(this)" data-type="11" data-index="1">
|
||||
<span class="mobile-reauth-tab-text" title="邮箱验证">邮箱验证</span>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
func TestParseMFAAuthMethodsMobileTabs(t *testing.T) {
|
||||
methods := parseMFAAuthMethods(mfaMobilePageSample)
|
||||
if len(methods) != 2 {
|
||||
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "5" || methods[0].Name != "今日校园验证码" || !methods[0].Default {
|
||||
t.Errorf("第 1 项解析错误: %+v", methods[0])
|
||||
}
|
||||
if methods[1].Type != "11" || methods[1].Name != "邮箱验证" || methods[1].Default {
|
||||
t.Errorf("第 2 项解析错误: %+v", methods[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsNewLayoutWithMoreMenu(t *testing.T) {
|
||||
page := `<html><body>
|
||||
<div class="reauth-tab-item reauth-tab-active" id="tab_13" data-type="13"></div>
|
||||
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
|
||||
<a class="reauth-tab-more-item" id="10">OTP</a>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 3 {
|
||||
t.Fatalf("期望解析出 3 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "13" || !methods[0].Default || methods[0].Name != "WeLink验证码" {
|
||||
t.Errorf("第 1 项解析错误(应取 data-type、active 标记、兜底名): %+v", methods[0])
|
||||
}
|
||||
if methods[1].Type != "3" || methods[1].Name != "短信验证码" {
|
||||
t.Errorf("第 2 项解析错误: %+v", methods[1])
|
||||
}
|
||||
if methods[2].Type != "10" || methods[2].Name != "OTP" {
|
||||
t.Errorf("更多菜单项解析错误(id 即编号、文本即名字): %+v", methods[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsLegacyLayout(t *testing.T) {
|
||||
page := `<html><body>
|
||||
<div class="changeReAuthTypes" id="3"></div>
|
||||
<div class="changeReAuthTypes" id="10"></div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 2 {
|
||||
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "3" || methods[1].Type != "10" {
|
||||
t.Errorf("旧版列表解析错误: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
// 许昌学院 xcuTheme:「选择认证方式」弹窗(结构取自真实 MFA 页面,已脱敏)
|
||||
const mfaOtherTypeDialogPageSample = `<html><body>
|
||||
<script>var reAuthParams = {
|
||||
"reAuthType":"3",
|
||||
"isMultifactor":"true",
|
||||
"service":"https:\/\/xcu.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
|
||||
"reAuthUserId":"5810260823"
|
||||
};</script>
|
||||
<div id="other_dialog">
|
||||
<div class="mask changeOtherType"></div>
|
||||
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
|
||||
<div class="other_type_title other_type_header_title">选择认证方式</div>
|
||||
<div id="3" class="other_type_button" onclick="changeMobileReAuthType(this)">短信验证码</div>
|
||||
<div class="other_type_title cancel_bottom_btn" onclick="mobileChangeTypeCancel();">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
func TestParseMFAAuthMethodsOtherTypeDialog(t *testing.T) {
|
||||
methods := parseMFAAuthMethods(mfaOtherTypeDialogPageSample)
|
||||
if len(methods) != 1 {
|
||||
t.Fatalf("弹窗方式列表应解析出 1 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "3" || methods[0].Name != "短信验证码" {
|
||||
t.Errorf("弹窗方式解析错误(id 即编号、文本即名字): %+v", methods[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsOtherTypeDialogMulti(t *testing.T) {
|
||||
// 多方式弹窗:标题不应被误解析,仅 .other_type_button 计入
|
||||
page := `<html><body>
|
||||
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
|
||||
<div class="other_type_title other_type_header_title">选择认证方式</div>
|
||||
<div id="3" class="other_type_button">短信验证码</div>
|
||||
<div id="11" class="other_type_button">邮箱验证</div>
|
||||
<div class="other_type_title cancel_bottom_btn">取消</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 2 {
|
||||
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
|
||||
}
|
||||
if methods[0].Type != "3" || methods[0].Name != "短信验证码" || methods[1].Type != "11" || methods[1].Name != "邮箱验证" {
|
||||
t.Errorf("多方式弹窗解析错误: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsDedupeAcrossLayouts(t *testing.T) {
|
||||
// 同一方式同时出现在 Tab 与弹窗时应去重(保留先出现项)
|
||||
page := `<html><body>
|
||||
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
|
||||
<div class="other_type_button" id="3">短信验证码</div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 1 || methods[0].Type != "3" {
|
||||
t.Fatalf("跨布局重复方式应去重,实际: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsIgnoresComments(t *testing.T) {
|
||||
page := `<html><body>
|
||||
<!-- <div class="mobile-reauth-tab-item" data-type="9"><span class="mobile-reauth-tab-text">QQ扫码</span></div> -->
|
||||
<div class="mobile-reauth-tab-item" data-type="5"><span class="mobile-reauth-tab-text">今日校园验证码</span></div>
|
||||
</body></html>`
|
||||
methods := parseMFAAuthMethods(page)
|
||||
if len(methods) != 1 || methods[0].Type != "5" {
|
||||
t.Fatalf("注释块内的方式不应被解析,实际: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMFAAuthMethodsEmptyPage(t *testing.T) {
|
||||
if methods := parseMFAAuthMethods("<html><body><p>no tabs</p></body></html>"); len(methods) != 0 {
|
||||
t.Fatalf("无方式列表的页面应返回空,实际: %+v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 方式列表规范化测试 ==========
|
||||
|
||||
func TestNormalizeMFAAuthMethodsFiltersNonDynamicCode(t *testing.T) {
|
||||
in := []MFAAuthMethod{
|
||||
{Type: "2", Name: "密码验证"},
|
||||
{Type: "5", Name: "今日校园验证码"},
|
||||
{Type: "11", Name: "邮箱验证"},
|
||||
{Type: "10", Name: "安全令牌OTP"},
|
||||
}
|
||||
out := normalizeMFAAuthMethods(in, "5")
|
||||
if len(out) != 2 || out[0].Type != "5" || out[1].Type != "11" {
|
||||
t.Fatalf("应过滤到仅动态码方式 [5,11],实际: %+v", out)
|
||||
}
|
||||
if !out[0].Default || out[1].Default {
|
||||
t.Errorf("默认项标记错误: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMFAAuthMethodsKeepsAllWhenNoDynamicCode(t *testing.T) {
|
||||
in := []MFAAuthMethod{
|
||||
{Type: "2", Name: "密码验证"},
|
||||
{Type: "6", Name: "实人认证"},
|
||||
}
|
||||
out := normalizeMFAAuthMethods(in, "2")
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("全为非动态码时应保留原列表,实际: %+v", out)
|
||||
}
|
||||
if !out[0].Default {
|
||||
t.Errorf("应按 defaultType 标记默认项: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMFAAuthMethodsDefaultFallbacks(t *testing.T) {
|
||||
// 页面未标记默认 + defaultType 匹配第二项
|
||||
in := []MFAAuthMethod{
|
||||
{Type: "5", Name: "Today"},
|
||||
{Type: "11", Name: "Email"},
|
||||
}
|
||||
out := normalizeMFAAuthMethods(in, "11")
|
||||
if !out[1].Default || out[0].Default {
|
||||
t.Fatalf("defaultType=11 应标记第二项: %+v", out)
|
||||
}
|
||||
// defaultType 不在列表时标记首项
|
||||
in2 := []MFAAuthMethod{
|
||||
{Type: "5", Name: "Today"},
|
||||
{Type: "3", Name: "SMS"},
|
||||
}
|
||||
out2 := normalizeMFAAuthMethods(in2, "9")
|
||||
if !out2[0].Default {
|
||||
t.Fatalf("defaultType 不在列表时应标记首项: %+v", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMFAAuthMethodsEmptyListFallback(t *testing.T) {
|
||||
// 页面无 Tab / 解析失败且当前方式支持动态码:用当前方式兜底为唯一选项,避免前端无可选
|
||||
out := normalizeMFAAuthMethods(nil, "5")
|
||||
if len(out) != 1 || out[0].Type != "5" || out[0].Name != "今日校园验证码" || !out[0].Default {
|
||||
t.Fatalf("空列表应按 reAuthType=5 兜底为唯一选项,实际: %+v", out)
|
||||
}
|
||||
// 当前方式不支持动态码(如实人认证)时不造选项,保持空
|
||||
if out := normalizeMFAAuthMethods(nil, "6"); len(out) != 0 {
|
||||
t.Fatalf("非动态码方式不应兜底,实际: %+v", out)
|
||||
}
|
||||
// defaultType 为空同样不猜测
|
||||
if out := normalizeMFAAuthMethods(nil, ""); len(out) != 0 {
|
||||
t.Fatalf("defaultType 为空不应兜底,实际: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SendMFADynamicCode 全流程测试(mock CAS 服务器) ==========
|
||||
|
||||
func TestSendMFADynamicCode(t *testing.T) {
|
||||
var (
|
||||
changeCalled bool
|
||||
changeForm map[string]string
|
||||
codeForm map[string]string
|
||||
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
|
||||
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/authserver/reAuthCheck/changeReAuthType.do":
|
||||
changeCalled = true
|
||||
_ = r.ParseForm()
|
||||
changeForm = map[string]string{
|
||||
"isMultifactor": r.Form.Get("isMultifactor"),
|
||||
"reAuthType": r.Form.Get("reAuthType"),
|
||||
"service": r.Form.Get("service"),
|
||||
}
|
||||
_, _ = w.Write([]byte(changeResp))
|
||||
case "/authserver/dynamicCode/getDynamicCodeByReauth.do":
|
||||
_ = r.ParseForm()
|
||||
codeForm = map[string]string{
|
||||
"userName": r.Form.Get("userName"),
|
||||
"authCodeTypeName": r.Form.Get("authCodeTypeName"),
|
||||
}
|
||||
_, _ = w.Write([]byte(codeResp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
newMFA := func() *MFAContext {
|
||||
return &MFAContext{
|
||||
BaseURL: server.URL,
|
||||
ContextPath: "/authserver",
|
||||
Service: "https://school.campusphere.net/iap/loginSuccess?sessionToken=t",
|
||||
ReAuthType: "5",
|
||||
UserID: "20220000000",
|
||||
AuthMethods: []MFAAuthMethod{
|
||||
{Type: "5", Name: "今日校园验证码", Default: true},
|
||||
{Type: "11", Name: "邮箱验证"},
|
||||
},
|
||||
CookiesByHost: map[string][]*http.Cookie{
|
||||
host: {{Name: "CASTGC", Value: "TGT-test"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
reset := func() {
|
||||
changeCalled = false
|
||||
changeForm = nil
|
||||
codeForm = nil
|
||||
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
|
||||
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
|
||||
}
|
||||
|
||||
t.Run("切换到新方式并发送", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
result, err := SendMFADynamicCode(context.Background(), mfa, "11")
|
||||
if err != nil {
|
||||
t.Fatalf("发送失败: %v", err)
|
||||
}
|
||||
if !changeCalled {
|
||||
t.Error("选择了与默认不同的方式,应先调用 changeReAuthType.do")
|
||||
}
|
||||
if changeForm["isMultifactor"] != "true" || changeForm["reAuthType"] != "11" || changeForm["service"] != mfa.Service {
|
||||
t.Errorf("切换参数错误: %+v", changeForm)
|
||||
}
|
||||
if codeForm["authCodeTypeName"] != "reAuthEmailDynamicCodeType" || codeForm["userName"] != "20220000000" {
|
||||
t.Errorf("发码参数错误: %+v", codeForm)
|
||||
}
|
||||
if result.Mobile != "138****0000" || result.CodeTime != 120 || result.Message != "验证码已发送成功!" {
|
||||
t.Errorf("发码结果解析错误: %+v", result)
|
||||
}
|
||||
if mfa.ReAuthType != "11" {
|
||||
t.Errorf("发送成功后应同步 ReAuthType=11,实际 %s", mfa.ReAuthType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("沿用默认方式不切换", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if err != nil {
|
||||
t.Fatalf("发送失败: %v", err)
|
||||
}
|
||||
if changeCalled {
|
||||
t.Error("选择与默认相同的方式不应调用 changeReAuthType.do")
|
||||
}
|
||||
if codeForm["authCodeTypeName"] != "reAuthCpdailyDynamicCodeType" {
|
||||
t.Errorf("发码参数错误: %+v", codeForm)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("切换响应 code 为字符串1也兼容", func(t *testing.T) {
|
||||
reset()
|
||||
changeResp = `{"code":"1","data":{"reAuthType":"11"}}`
|
||||
mfa := newMFA()
|
||||
if _, err := SendMFADynamicCode(context.Background(), mfa, "11"); err != nil {
|
||||
t.Fatalf("字符串 code=1 应视为切换成功,实际报错: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不在可用列表的方式被拒绝", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
if _, err := SendMFADynamicCode(context.Background(), mfa, "2"); err == nil {
|
||||
t.Fatal("不在列表中的方式应报错")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("列表内非动态码方式被拒绝", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
mfa.AuthMethods = append(mfa.AuthMethods, MFAAuthMethod{Type: "6", Name: "实人认证"})
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "6")
|
||||
if err == nil || !strings.Contains(err.Error(), "暂不支持发送验证码") {
|
||||
t.Fatalf("非动态码方式应明确报错,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("会话缺失返回过期错误", func(t *testing.T) {
|
||||
reset()
|
||||
mfa := newMFA()
|
||||
mfa.CookiesByHost = nil
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if !errors.Is(err, ErrMFASessionExpired) {
|
||||
t.Fatalf("会话缺失应返回 ErrMFASessionExpired,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("发码失败透传服务端文案", func(t *testing.T) {
|
||||
reset()
|
||||
codeResp = `{"res":"fail","returnMessage":"发送过于频繁,请稍后再试"}`
|
||||
mfa := newMFA()
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if err == nil || !strings.Contains(err.Error(), "发送过于频繁") {
|
||||
t.Fatalf("应透传服务端失败文案,实际: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("倒计时未结束视为已发送", func(t *testing.T) {
|
||||
reset()
|
||||
codeResp = `{"res":"code_time_fail","returnMessage":"验证码已发送,请勿重复获取","codeTime":60}`
|
||||
mfa := newMFA()
|
||||
result, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if err != nil {
|
||||
t.Fatalf("code_time_fail 应视为发送成功: %v", err)
|
||||
}
|
||||
if result.CodeTime != 60 {
|
||||
t.Errorf("应回传倒计时秒数,实际: %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("会话失效错误码映射为过期", func(t *testing.T) {
|
||||
reset()
|
||||
codeResp = `{"res":"fail","errCode":"206302","returnMessage":"会话失效"}`
|
||||
mfa := newMFA()
|
||||
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
|
||||
if !errors.Is(err, ErrMFASessionExpired) {
|
||||
t.Fatalf("errCode=206302 应映射为 ErrMFASessionExpired,实际: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 许昌学院(xcu)单独适配 ==========
|
||||
|
||||
// XcuSchoolId 许昌学院 tenant id
|
||||
const XcuSchoolId = "xcu"
|
||||
|
||||
// xcuCasLoginEntry 计算许昌学院 CAS 登录入口:
|
||||
// 校园网关的 /portal/login 会 302 到学校 CAS(authserver.xcu.edu.cn),并在 service 中
|
||||
// 携带网关回调(/iap/loginSuccess?sessionToken=...);走完 CAS 账密 + MFA 后回调该地址,
|
||||
// cookie 才能正确落在 xcu.campusphere.net 域(wec-* 任务接口所在域,activateSession 依赖)。
|
||||
// WecloudUrl 缺失时回退用学校 ampUrl(仅保证能走通 CAS 账密 + MFA)。
|
||||
func xcuCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/portal/login"
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// xcuCasLogin 许昌学院强制走 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但其 IAP 账密接口 /iap/doLogin 与通用
|
||||
// IAP 密码加密格式不兼容(实测 RSA hex/base64 等多种编码均返回 FAIL_UPNOTMATCH);
|
||||
// 实际登录链路为 IAP 入口 302 → 学校 CAS 账密 → MFA → 回调 /iap/loginSuccess 建立网关会话。
|
||||
// 因此不走通用 IAP 流程,强制用 CAS 流程并从校园网关入口进入(详见 xcuCasLoginEntry)。
|
||||
func xcuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
xcu := *school
|
||||
if entry := xcuCasLoginEntry(school); entry != "" {
|
||||
xcu.AmpUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &xcu, username, password)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestXcuCasLoginEntry 覆盖许昌学院 CAS 登录入口计算逻辑
|
||||
func TestXcuCasLoginEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
school *SchoolDetailInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "正常wecloud",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net"},
|
||||
want: "https://xcu.campusphere.net/portal/login",
|
||||
},
|
||||
{
|
||||
name: "wecloud带尾斜杠",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net/"},
|
||||
want: "https://xcu.campusphere.net/portal/login",
|
||||
},
|
||||
{
|
||||
name: "wecloud缺失回退ampUrl",
|
||||
school: &SchoolDetailInfo{
|
||||
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
|
||||
},
|
||||
want: "https://casp.xcu.edu.cn/newmobile/client",
|
||||
},
|
||||
{
|
||||
name: "均为空",
|
||||
school: &SchoolDetailInfo{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil学校",
|
||||
school: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := xcuCasLoginEntry(c.school); got != c.want {
|
||||
t.Errorf("xcuCasLoginEntry() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXcuCasLoginEntryOverridesAmpUrl 确认 xcuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
|
||||
func TestXcuCasLoginEntryOverridesAmpUrl(t *testing.T) {
|
||||
school := &SchoolDetailInfo{
|
||||
ID: XcuSchoolId,
|
||||
Name: "许昌学院",
|
||||
JoinType: "CLOUD",
|
||||
IdsUrl: "https://authserver.xcu.edu.cn/authserver",
|
||||
WecloudUrl: "https://xcu.campusphere.net",
|
||||
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
|
||||
}
|
||||
// 模拟 xcuCasLogin 内部的入口覆盖(不发起网络请求)
|
||||
clone := *school
|
||||
clone.AmpUrl = xcuCasLoginEntry(school)
|
||||
if got := getCasValidLoginUrl(&clone); got != "https://xcu.campusphere.net/portal/login" {
|
||||
t.Errorf("getCasValidLoginUrl() = %q, want 网关入口", got)
|
||||
}
|
||||
// 原对象不被修改
|
||||
if school.AmpUrl != "https://casp.xcu.edu.cn/newmobile/client" {
|
||||
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/makiuchi-d/gozxing"
|
||||
"github.com/makiuchi-d/gozxing/qrcode"
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// qrUuidPattern 匹配二维码内容中的 uuid 参数(如 ...?uuid=1583a6...&isNeedExtra=0)
|
||||
var qrUuidPattern = regexp.MustCompile(`uuid=([0-9a-zA-Z_-]+)`)
|
||||
|
||||
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串)
|
||||
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
|
||||
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
|
||||
// 支持完整链接(取 uuid= 参数值)或纯 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)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DecodeQRFromImage 解码图片数据中的二维码,返回其中文本内容。
|
||||
// 先直接解码,失败后放大图片重试(应对小尺寸/低分辨率截图)。
|
||||
func DecodeQRFromImage(data []byte) (string, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("识别图片格式失败: %w", err)
|
||||
}
|
||||
|
||||
text, err := decodeQRImage(img)
|
||||
if err == nil {
|
||||
return text, nil
|
||||
}
|
||||
// 放大 3 倍重试
|
||||
if text, err2 := decodeQRImage(scaleImage(img, 3)); err2 == nil {
|
||||
return text, nil
|
||||
}
|
||||
return "", fmt.Errorf("未能识别图片中的二维码: %w", err)
|
||||
}
|
||||
|
||||
func decodeQRImage(img image.Image) (string, error) {
|
||||
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hints := map[gozxing.DecodeHintType]interface{}{
|
||||
gozxing.DecodeHintType_TRY_HARDER: true,
|
||||
}
|
||||
result, err := qrcode.NewQRCodeReader().Decode(bmp, hints)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.GetText(), nil
|
||||
}
|
||||
|
||||
// scaleImage 将图片按整数倍放大(用于提高小图二维码的识别率)
|
||||
func scaleImage(img image.Image, factor int) image.Image {
|
||||
b := img.Bounds()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*factor, b.Dy()*factor))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// ResolveQrUuidFromContent 从用户提供的二维码内容中解析出签到 uuid。
|
||||
// 支持三种形式:
|
||||
// 1. 文本直填(二维码链接或纯 uuid)
|
||||
// 2. base64 图片(dataURI,前端上传的本地预览图)
|
||||
// 3. http(s) 图片链接(下载后解码)
|
||||
func ResolveQrUuidFromContent(content string) (string, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("尚未上传签到二维码")
|
||||
}
|
||||
|
||||
// 1) 内容本身已是文本(链接或 uuid),直接提取
|
||||
if uuid := ExtractQrUuid(content); uuid != "" {
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
// 2) base64 图片(dataURI)
|
||||
if IsDataURIImage(content) {
|
||||
idx := strings.Index(content, ",")
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("二维码图片数据格式不正确")
|
||||
}
|
||||
imgBytes, err := base64.StdEncoding.DecodeString(content[idx+1:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码二维码图片失败: %w", err)
|
||||
}
|
||||
return decodeQrTextToUuid(imgBytes)
|
||||
}
|
||||
|
||||
// 3) http(s) 图片链接:下载后解码
|
||||
if strings.HasPrefix(content, "http://") || strings.HasPrefix(content, "https://") {
|
||||
client := resty.New().SetTimeout(30 * time.Second)
|
||||
resp, err := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.81 Mobile Safari/537.36").
|
||||
Get(content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("下载二维码图片失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode() > 299 {
|
||||
return "", fmt.Errorf("下载二维码图片失败(HTTP %d)", resp.StatusCode())
|
||||
}
|
||||
return decodeQrTextToUuid(resp.Body())
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("无法识别的二维码内容,请上传二维码图片或粘贴二维码链接")
|
||||
}
|
||||
|
||||
func decodeQrTextToUuid(imgBytes []byte) (string, error) {
|
||||
text, err := DecodeQRFromImage(imgBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if uuid := ExtractQrUuid(text); uuid != "" {
|
||||
return uuid, nil
|
||||
}
|
||||
return "", fmt.Errorf("二维码内容中未找到 uuid: %s", text)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"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", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ExtractQrUuid(c.in); got != c.want {
|
||||
t.Errorf("ExtractQrUuid(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQrUuidFromLinkText(t *testing.T) {
|
||||
uuid, err := ResolveQrUuidFromContent("https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0")
|
||||
if err != nil || uuid != testQrUuid {
|
||||
t.Fatalf("link text resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
|
||||
}
|
||||
if _, err := ResolveQrUuidFromContent(""); err == nil {
|
||||
t.Fatal("empty content should return error")
|
||||
}
|
||||
if _, err := ResolveQrUuidFromContent("some random text"); err == nil {
|
||||
t.Fatal("unrecognized content should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeQrImageAndResolve(t *testing.T) {
|
||||
imgBytes, err := os.ReadFile("testdata/test_qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("read test qr image: %v", err)
|
||||
}
|
||||
|
||||
// 1) 直接解码图片中的二维码
|
||||
text, err := DecodeQRFromImage(imgBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("decode qr image: %v", err)
|
||||
}
|
||||
if got := ExtractQrUuid(text); got != testQrUuid {
|
||||
t.Fatalf("decoded text %q -> uuid %q, want %q", text, got, testQrUuid)
|
||||
}
|
||||
|
||||
// 2) dataURI 形式(前端本地上传的图片)
|
||||
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes)
|
||||
uuid, err := ResolveQrUuidFromContent(dataURI)
|
||||
if err != nil || uuid != testQrUuid {
|
||||
t.Fatalf("dataURI resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
|
||||
}
|
||||
}
|
||||
@@ -157,22 +157,41 @@ func hasUsableLoginUrl(school *SchoolDetailInfo) bool {
|
||||
}
|
||||
|
||||
// CpdailyLogin 执行 cpdaily 登录
|
||||
// 返回登录成功后的所有 cookies
|
||||
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
// 返回登录成功后的所有 cookies;若触发多因子认证(MFA),返回 (nil, mfaCtx, ErrMFARequired),
|
||||
// 调用方应暂存 mfaCtx 并引导用户完成验证码校验(CompleteMFALogin)。
|
||||
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
// 南昌理工学院:自研 CAS→IAP 混合认证,与通用 IAP/CAS 流程均不兼容,单独适配
|
||||
if school.ID == NutSchoolId {
|
||||
return nutCasIapLogin(ctx, school, username, password)
|
||||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
if school.ID == HfnuSchoolId {
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 许昌学院:平台配置 joinType=CLOUD(IsIap=true),但 IAP 账密登录实际不兼容;
|
||||
// 学校真实链路为 CAS 账密 + MFA + /iap/loginSuccess 回调,单独强制走 CAS(详见 login_xcu.go)
|
||||
if school.ID == XcuSchoolId {
|
||||
return xcuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
|
||||
return nil, nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
|
||||
}
|
||||
return iapLogin(ctx, school, username, password)
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("CAS学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
return nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
|
||||
return nil, nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
|
||||
}
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -141,25 +140,22 @@ func (r *submitCaptchaResolver) GetCaptchaTicket(info *ValidationInfo) (string,
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// createCaptcha 创建验证码(JSON body)
|
||||
// createCaptcha 创建验证码(必须用 multipart,JSON body 会返回空壳响应导致验证码识别为空)
|
||||
func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCaptcha, error) {
|
||||
baseURL := strings.TrimSuffix(r.host, "/")
|
||||
url := baseURL + "/captcha-open-api/v1/captcha/create/scenesImage"
|
||||
|
||||
client := newTaskClient()
|
||||
|
||||
reqData := map[string]string{
|
||||
"accountKey": info.AccountKey,
|
||||
"sceneCode": info.SceneCode,
|
||||
"tenantId": info.TenantId,
|
||||
"userId": info.UserId,
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Cookie", r.cookieStr).
|
||||
SetHeader("deviceId", r.deviceId).
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetBody(reqData).
|
||||
SetMultipartFormData(map[string]string{
|
||||
"accountKey": info.AccountKey,
|
||||
"sceneCode": info.SceneCode,
|
||||
"tenantId": info.TenantId,
|
||||
"userId": info.UserId,
|
||||
}).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建验证码请求失败: %w", err)
|
||||
@@ -229,12 +225,11 @@ func (r *submitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage)
|
||||
return itResp.Data, nil
|
||||
}
|
||||
|
||||
// submitCaptcha 提交验证码结果(application/x-www-form-urlencoded)
|
||||
// submitCaptcha 提交验证码结果(multipart,与旧版一致)
|
||||
func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo *CpSubmitCaptcha, codes []string) (string, error) {
|
||||
baseURL := strings.TrimSuffix(r.host, "/")
|
||||
targetURL := baseURL + "/captcha-open-api/v1/captcha/validate/scenesImage"
|
||||
|
||||
// 构建 URL-encoded 表单数据
|
||||
if captchaInfo.Code == "" {
|
||||
return "", fmt.Errorf("创建验证码返回的场景码为空")
|
||||
}
|
||||
@@ -242,23 +237,22 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
|
||||
return "", fmt.Errorf("验证码识别结果为空,不提交空验证码")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("accountKey", info.AccountKey)
|
||||
form.Set("sceneCode", info.SceneCode)
|
||||
form.Set("tenantId", info.TenantId)
|
||||
form.Set("userId", info.UserId)
|
||||
form.Set("scenesImageCode", captchaInfo.Code)
|
||||
for _, code := range codes {
|
||||
form.Add("scenesImageCodes", code)
|
||||
}
|
||||
|
||||
client := newTaskClient()
|
||||
resp, err := client.R().
|
||||
req := client.R().
|
||||
SetHeader("Cookie", r.cookieStr).
|
||||
SetHeader("deviceId", r.deviceId).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetBody(form.Encode()).
|
||||
Post(targetURL)
|
||||
SetMultipartFormData(map[string]string{
|
||||
"accountKey": info.AccountKey,
|
||||
"sceneCode": info.SceneCode,
|
||||
"tenantId": info.TenantId,
|
||||
"userId": info.UserId,
|
||||
"scenesImageCode": captchaInfo.Code,
|
||||
})
|
||||
for _, code := range codes {
|
||||
req = req.SetMultipartField("scenesImageCodes", "", "", strings.NewReader(code))
|
||||
}
|
||||
|
||||
resp, err := req.Post(targetURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("提交验证码请求失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,7 @@ type TaskType string
|
||||
const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -49,20 +52,20 @@ type TodayTaskInfo struct {
|
||||
|
||||
// TaskFormDetail 签到任务表单详情
|
||||
type TaskFormDetail struct {
|
||||
Rows map[string]interface{} `json:"rows"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
SignWid string `json:"signWid"`
|
||||
SignInstanceWid string `json:"signInstanceWid"`
|
||||
IsPhoto bool `json:"isPhoto"`
|
||||
IsNeedExtra bool `json:"isNeedExtra"`
|
||||
ExtraField []interface{} `json:"extraField,omitempty"`
|
||||
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
|
||||
Rows map[string]interface{} `json:"rows"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
SignWid string `json:"signWid"`
|
||||
SignInstanceWid string `json:"signInstanceWid"`
|
||||
IsPhoto bool `json:"isPhoto"`
|
||||
IsNeedExtra bool `json:"isNeedExtra"`
|
||||
ExtraField []interface{} `json:"extraField,omitempty"`
|
||||
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
|
||||
}
|
||||
|
||||
// newRestyClient 创建 resty 客户端
|
||||
func newTaskClient() *resty.Client {
|
||||
return resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetTimeout(30 * time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
}
|
||||
@@ -324,7 +327,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
|
||||
todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType)
|
||||
for _, t := range todayTasks {
|
||||
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
|
||||
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
if err == nil && detail != nil {
|
||||
return detail.Rows
|
||||
}
|
||||
@@ -339,7 +342,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
|
||||
monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
|
||||
for _, t := range monthTasks {
|
||||
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
|
||||
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||
if err == nil && detail != nil {
|
||||
return detail.Rows
|
||||
}
|
||||
@@ -363,60 +366,53 @@ func mapValueStr(m map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// FetchTaskDetail 获取签到任务详情(表单字段)
|
||||
// 任务实例过期(详情返回空)时自动匹配同名任务的最新可用实例重试
|
||||
func FetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||
return fetchTaskDetail(cookieMap, host, taskType, signWid, signInstanceWid, title, true)
|
||||
// GetTaskDetailByWid 按 wid 直接拉取任务详情(表单预览、历史表单预填充等场景,无任何回退)
|
||||
func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
|
||||
}
|
||||
|
||||
// findLatestTaskInstance 查找同名任务的最新可用实例
|
||||
// 优先当天/近期列表中未签到的实例(循环任务当天实例,可正常提交);
|
||||
// 否则回退到已签到实例(用于还原表单结构与历史默认值)
|
||||
func findLatestTaskInstance(cookieMap map[string]string, host string, taskType TaskType, title, excludeInstanceWid string) (TodayTaskInfo, bool) {
|
||||
collect := func(yearMonth string) ([]TodayTaskInfo, error) {
|
||||
if yearMonth == "" {
|
||||
return fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
}
|
||||
return fetchTasksByYearMonth(cookieMap, host, taskType, yearMonth[:4], yearMonth[5:])
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
|
||||
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
|
||||
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
|
||||
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当日任务列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 依次:当天列表、本月列表、上月列表
|
||||
now := time.Now()
|
||||
months := []string{"", now.Format("2006-01"), now.AddDate(0, -1, 0).Format("2006-01")}
|
||||
seen := map[string]bool{}
|
||||
for _, ym := range months {
|
||||
tasks, err := collect(ym)
|
||||
if err != nil || len(tasks) == 0 {
|
||||
var signed, target *TodayTaskInfo
|
||||
for i := range tasks {
|
||||
t := tasks[i]
|
||||
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" {
|
||||
continue
|
||||
}
|
||||
var signedFallback *TodayTaskInfo
|
||||
for i := range tasks {
|
||||
t := tasks[i]
|
||||
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" || t.SignInstanceWid == excludeInstanceWid {
|
||||
continue
|
||||
}
|
||||
if seen[t.SignInstanceWid] {
|
||||
continue
|
||||
}
|
||||
seen[t.SignInstanceWid] = true
|
||||
if !t.IsSigned {
|
||||
// 未签到实例优先(当天循环任务可正常提交)
|
||||
return t, true
|
||||
}
|
||||
if signedFallback == nil {
|
||||
s := t
|
||||
signedFallback = &s
|
||||
}
|
||||
if !t.IsSigned {
|
||||
target = &t
|
||||
break
|
||||
}
|
||||
if signedFallback != nil {
|
||||
return *signedFallback, true
|
||||
if signed == nil {
|
||||
s := t
|
||||
signed = &s
|
||||
}
|
||||
}
|
||||
return TodayTaskInfo{}, false
|
||||
if target == nil {
|
||||
if signed != nil {
|
||||
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
|
||||
}
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
}
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if detail.Rows == nil {
|
||||
return nil, fmt.Errorf("获取「%s」任务详情为空", title)
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// fetchTaskDetail 拉取任务详情;allowFallback 为 true 且实例已过期(datas 为空)时自动匹配最新实例重试一次
|
||||
func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string, allowFallback bool) (*TaskFormDetail, error) {
|
||||
// fetchTaskDetailByWid 按 wid 拉取任务详情(纯查询,无任何回退逻辑)
|
||||
func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||
client := newTaskClient()
|
||||
cookieStr := cookiesToStr(cookieMap)
|
||||
baseURL := taskBasePath(host, taskType)
|
||||
@@ -451,13 +447,6 @@ func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType
|
||||
}
|
||||
|
||||
rows := result.Datas
|
||||
if rows == nil && allowFallback {
|
||||
// 实例已过期/当天实例未发布导致详情为空,自动匹配同名任务的最新可用实例重试
|
||||
slog.Info("任务详情数据为空,自动匹配最新实例", "title", title, "signInstanceWid", signInstanceWid, "taskType", taskType)
|
||||
if alt, ok := findLatestTaskInstance(cookieMap, host, taskType, title, signInstanceWid); ok {
|
||||
return fetchTaskDetail(cookieMap, host, taskType, alt.SignWid, alt.SignInstanceWid, alt.TaskName, false)
|
||||
}
|
||||
}
|
||||
|
||||
detail := &TaskFormDetail{
|
||||
Rows: rows,
|
||||
@@ -497,6 +486,11 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histRows = historyRows
|
||||
}
|
||||
|
||||
// 扫码签到任务(signMode=2):只需要签到二维码(可选照片/额外字段),不需要定位信息
|
||||
if isScanMode(rows) {
|
||||
return parseScanTaskForm(detail, histRows)
|
||||
}
|
||||
|
||||
// 提取历史值(与旧版系统格式一致:lon/lat/address 分开存储)
|
||||
histLon := ""
|
||||
histLat := ""
|
||||
@@ -731,8 +725,125 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
return list
|
||||
}
|
||||
|
||||
// parseScanTaskForm 生成扫码签到任务(signMode=2)的表单项:
|
||||
// 签到二维码(必填,支持上传图片或粘贴链接)+ 可选签到照片 + 可选额外字段
|
||||
func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
|
||||
rows := detail.Rows
|
||||
var list []cpdmodel.CpTaskFormItem
|
||||
|
||||
// 签到二维码
|
||||
histQr := ""
|
||||
if histRows != nil {
|
||||
if v, ok := histRows["qrCode"]; ok && v != nil {
|
||||
histQr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "签到二维码",
|
||||
ColName: "qrCode",
|
||||
Type: cpdmodel.ItemTypeImage,
|
||||
Required: true,
|
||||
Content: histQr,
|
||||
Show: true,
|
||||
Tip: "上传现场签到二维码图片(或直接粘贴二维码链接/内容),执行时自动解析出 uuid 提交",
|
||||
})
|
||||
|
||||
// 签到照片(任务要求时保留)
|
||||
isPhoto := detail.IsPhoto
|
||||
if !isPhoto && histRows != nil {
|
||||
if v, ok := histRows["isPhoto"]; ok {
|
||||
if f, ok := v.(float64); ok && f == 1 {
|
||||
isPhoto = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if isPhoto {
|
||||
histSignPhotoUrl := ""
|
||||
if histRows != nil {
|
||||
if photo, ok := histRows["signPhotoUrl"]; ok && photo != nil {
|
||||
histSignPhotoUrl = fmt.Sprintf("%v", photo)
|
||||
}
|
||||
}
|
||||
if histSignPhotoUrl == "" {
|
||||
if photo, ok := rows["signPhotoUrl"]; ok && photo != nil {
|
||||
histSignPhotoUrl = fmt.Sprintf("%v", photo)
|
||||
}
|
||||
}
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "签到照片",
|
||||
ColName: "signImg",
|
||||
Type: cpdmodel.ItemTypeImage,
|
||||
Required: true,
|
||||
Content: histSignPhotoUrl,
|
||||
Default: histSignPhotoUrl,
|
||||
Show: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 额外字段
|
||||
if detail.IsNeedExtra {
|
||||
for i, field := range detail.ExtraField {
|
||||
fm, ok := field.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
extraTitle, _ := fm["title"].(string)
|
||||
isReq, _ := fm["isRequired"].(bool)
|
||||
hasOther, _ := fm["hasOtherItems"].(bool)
|
||||
|
||||
item := cpdmodel.CpTaskFormItem{
|
||||
Title: extraTitle,
|
||||
ColName: extraTitle,
|
||||
Type: cpdmodel.ItemTypeSingle,
|
||||
Number: uint(i + 1),
|
||||
Required: isReq,
|
||||
IsOther: hasOther,
|
||||
Show: true,
|
||||
}
|
||||
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
|
||||
for _, it := range items {
|
||||
if im, ok := it.(map[string]interface{}); ok {
|
||||
content, _ := im["content"].(string)
|
||||
otherItems, _ := im["isOtherItems"].(bool)
|
||||
item.Options = append(item.Options, cpdmodel.CpTaskFormOption{
|
||||
Content: content,
|
||||
IsOtherItems: otherItems,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// FillSubmitForm 根据用户配置的表单项填充提交表单
|
||||
func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) map[string]interface{} {
|
||||
// isScanMode 判断任务是否为扫码签到类型(signMode=2,兼容数字/字符串/json.Number)
|
||||
func isScanMode(rows map[string]interface{}) bool {
|
||||
if rows == nil {
|
||||
return false
|
||||
}
|
||||
switch v := rows["signMode"].(type) {
|
||||
case float64:
|
||||
return int(v) == 2
|
||||
case int:
|
||||
return v == 2
|
||||
case string:
|
||||
return v == "2"
|
||||
case json.Number:
|
||||
return v.String() == "2"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) (map[string]interface{}, error) {
|
||||
// 扫码签到任务(signMode=2):从签到二维码解析 uuid,提交体用 qrUuid 替代 signInstanceWid
|
||||
if isScanMode(rows) {
|
||||
return fillScanSubmitForm(rows, formItems)
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// 提取经纬度和地址(兼容旧版 lon/lat 和新版 latAndLon 两种格式)
|
||||
@@ -805,6 +916,13 @@ func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskForm
|
||||
}
|
||||
|
||||
// 额外字段
|
||||
fillExtraFields(rows, formItems, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fillExtraFields 填充任务额外字段(isNeedExtra)到提交表单
|
||||
func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, result map[string]interface{}) {
|
||||
isNeedExtra := false
|
||||
if v, ok := rows["isNeedExtra"]; ok {
|
||||
if f, ok := v.(float64); ok && f == 1 {
|
||||
@@ -847,8 +965,175 @@ func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskForm
|
||||
} else {
|
||||
result["isNeedExtra"] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
|
||||
// 解析签到二维码(图片/链接)得到 uuid,用 qrUuid 替代 signInstanceWid(两者不并存),
|
||||
// 不携带经纬度等定位字段。
|
||||
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
|
||||
qrContent := ""
|
||||
for _, v := range formItems {
|
||||
if v.ColName == "qrCode" {
|
||||
qrContent = strings.TrimSpace(v.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
uuid, err := ResolveQrUuidFromContent(qrContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("二维码签到任务缺少有效签到二维码: %w", err)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"qrUuid": uuid,
|
||||
"uaIsCpadaily": true,
|
||||
}
|
||||
|
||||
// 签到照片(任务要求时保留,与普通签到一致)
|
||||
isPhoto := false
|
||||
if v, ok := rows["isPhoto"]; ok {
|
||||
if f, ok := v.(float64); ok && f == 1 {
|
||||
isPhoto = true
|
||||
}
|
||||
}
|
||||
if isPhoto {
|
||||
for _, v := range formItems {
|
||||
if v.ColName == "signImg" && v.Content != "" {
|
||||
result["signPhotoUrl"] = v.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 额外字段(与普通签到保持一致的提交结构)
|
||||
fillExtraFields(rows, formItems, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SignPlace 查寝组/签到任务配置的签到范围点
|
||||
type SignPlace struct {
|
||||
Address string
|
||||
Longitude string // 原始字符串,保持精度
|
||||
Latitude string // 原始字符串,保持精度
|
||||
LongitudeF float64 // 用于距离计算
|
||||
LatitudeF float64
|
||||
RadiusMeters float64
|
||||
}
|
||||
|
||||
// parseSignPlaces 解析任务详情中的 signPlaceSelected(兼容 JSON 字符串与数组两种格式)
|
||||
func parseSignPlaces(rows map[string]interface{}) []SignPlace {
|
||||
if rows == nil {
|
||||
return nil
|
||||
}
|
||||
var places []SignPlace
|
||||
appendPlace := func(m map[string]interface{}) {
|
||||
lon := mapValueStr(m, "longitude")
|
||||
lat := mapValueStr(m, "latitude")
|
||||
if lon == "" || lat == "" {
|
||||
return
|
||||
}
|
||||
lonF, err1 := strconv.ParseFloat(lon, 64)
|
||||
latF, err2 := strconv.ParseFloat(lat, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return
|
||||
}
|
||||
radius := 0.0
|
||||
if r, ok := m["radius"]; ok && r != nil {
|
||||
if f, ok := r.(float64); ok {
|
||||
radius = f
|
||||
} else {
|
||||
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
|
||||
}
|
||||
}
|
||||
if radius <= 0 {
|
||||
return
|
||||
}
|
||||
places = append(places, SignPlace{
|
||||
Address: mapValueStr(m, "address"),
|
||||
Longitude: lon,
|
||||
Latitude: lat,
|
||||
LongitudeF: lonF,
|
||||
LatitudeF: latF,
|
||||
RadiusMeters: radius,
|
||||
})
|
||||
}
|
||||
if sps, ok := rows["signPlaceSelected"]; ok {
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
var arr []map[string]interface{}
|
||||
if json.Unmarshal([]byte(v), &arr) == nil {
|
||||
for _, m := range arr {
|
||||
appendPlace(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
appendPlace(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return places
|
||||
}
|
||||
|
||||
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
|
||||
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
rad := math.Pi / 180.0
|
||||
dLat := (lat2 - lat1) * rad
|
||||
dLon := (lon2 - lon1) * rad
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return 2 * earthRadius * math.Asin(math.Sqrt(a))
|
||||
}
|
||||
|
||||
// AdjustSubmitFormToSignRange 提交前校验历史位置是否落在查寝组签到范围内,
|
||||
// 若超出所有签到点范围则自动改用距离最近的签到点坐标(避免服务端拒绝"位置不在签到范围内")。
|
||||
// 返回 true 表示发生了坐标调整。
|
||||
func AdjustSubmitFormToSignRange(submitForm map[string]interface{}, detailRows map[string]interface{}) bool {
|
||||
places := parseSignPlaces(detailRows)
|
||||
if len(places) == 0 {
|
||||
return false
|
||||
}
|
||||
latStr, _ := submitForm["latitude"].(string)
|
||||
lonStr, _ := submitForm["longitude"].(string)
|
||||
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
|
||||
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
best := -1
|
||||
bestDist := math.MaxFloat64
|
||||
for i, p := range places {
|
||||
d := haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF)
|
||||
if d <= p.RadiusMeters {
|
||||
// 历史位置在某个签到点范围内,正常沿用历史记录
|
||||
return false
|
||||
}
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
p := places[best]
|
||||
submitForm["latitude"] = p.Latitude
|
||||
submitForm["longitude"] = p.Longitude
|
||||
if p.Address != "" {
|
||||
submitForm["position"] = p.Address
|
||||
}
|
||||
slog.Info("历史位置超出查寝组所有签到范围,自动改用最近签到点",
|
||||
"origLat", latStr, "origLon", lonStr,
|
||||
"newLat", p.Latitude, "newLon", p.Longitude,
|
||||
"place", p.Address, "distM", int(bestDist), "radiusM", int(p.RadiusMeters))
|
||||
return true
|
||||
}
|
||||
|
||||
// GetBodyString 加密提交表单为 bodyString
|
||||
@@ -967,6 +1252,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("submitSign 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 模拟阜阳职业技术学院查寝组真实返回的签到点配置
|
||||
func mockSignPlacesRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"signPlaceSelected": `[{"address":"七渔河东区","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":500},
|
||||
{"address":"新建的签到地点","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":790},
|
||||
{"address":"新校区1000米","longitude":"115.77058016419615","latitude":"32.904624876025075","radius":1000},
|
||||
{"address":"新校区边角修正2","longitude":"115.77471236954204","latitude":"32.9053826914217","radius":500},
|
||||
{"address":"新校区边角修正1","longitude":"115.76709473881743","latitude":"32.90250295808209","radius":500}]`,
|
||||
}
|
||||
}
|
||||
|
||||
// 历史位置在"新校区1000米"点范围内(约932m)→ 不调整,沿用历史记录
|
||||
func TestAdjustInKeepHistory(t *testing.T) {
|
||||
form := map[string]interface{}{
|
||||
"latitude": "32.898615",
|
||||
"longitude": "115.763603",
|
||||
"position": "七渔河东区",
|
||||
}
|
||||
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
|
||||
if adjusted {
|
||||
t.Fatalf("历史位置在范围内不应调整, form=%v", form)
|
||||
}
|
||||
if form["latitude"] != "32.898615" || form["longitude"] != "115.763603" {
|
||||
t.Fatalf("范围内历史坐标不应被修改: %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史位置远离所有签到点(2km+)→ 调整为最近签到点坐标
|
||||
func TestAdjustOutOfRange(t *testing.T) {
|
||||
form := map[string]interface{}{
|
||||
"latitude": "32.880000",
|
||||
"longitude": "115.740000",
|
||||
"position": "旧位置",
|
||||
}
|
||||
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
|
||||
if !adjusted {
|
||||
t.Fatal("完全出界的历史位置应被调整")
|
||||
}
|
||||
if form["longitude"] != "115.76709473881743" || form["latitude"] != "32.90250295808209" {
|
||||
t.Fatalf("应调整为最近的'边角修正1'坐标, got %v", form)
|
||||
}
|
||||
if form["position"] != "新校区边角修正1" {
|
||||
t.Fatalf("地址应同步为最近签到点: %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
// 无 signPlaceSelected / 无 radius 时保持不动
|
||||
func TestAdjustNoPlaces(t *testing.T) {
|
||||
form := map[string]interface{}{"latitude": "32.88", "longitude": "115.74"}
|
||||
if AdjustSubmitFormToSignRange(form, map[string]interface{}{}) {
|
||||
t.Fatal("无签到点配置不应调整")
|
||||
}
|
||||
if AdjustSubmitFormToSignRange(form, map[string]interface{}{"signPlaceSelected": "[]"}) {
|
||||
t.Fatal("空签到点不应调整")
|
||||
}
|
||||
}
|
||||
|
||||
// 坐标解析异常时保持不动
|
||||
func TestAdjustBadCoord(t *testing.T) {
|
||||
form := map[string]interface{}{"latitude": "", "longitude": "abc"}
|
||||
if AdjustSubmitFormToSignRange(form, mockSignPlacesRows()) {
|
||||
t.Fatal("坐标异常不应调整")
|
||||
}
|
||||
}
|
||||
|
||||
// haversine 距离合理性:边角修正1距历史位置约540m,七渔河东区约2.26km
|
||||
func TestHaversineMeters(t *testing.T) {
|
||||
d1 := haversineMeters(32.898615, 115.763603, 32.90250295808209, 115.76709473881743)
|
||||
if d1 < 480 || d1 > 600 {
|
||||
t.Fatalf("距边角修正1应约540m, got %.0f", d1)
|
||||
}
|
||||
d2 := haversineMeters(32.898615, 115.763603, 32.90506452710031, 115.78725271446127)
|
||||
if d2 < 2000 || d2 > 2500 {
|
||||
t.Fatalf("距七渔河东区应约2.26km, got %.0f", d2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cpdmodel "cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,且不携带经纬度
|
||||
func TestFillScanSubmitForm(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"signMode": float64(2),
|
||||
"signInstanceWid": "397161",
|
||||
}
|
||||
formItems := []cpdmodel.CpTaskFormItem{
|
||||
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage,
|
||||
Content: "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0"},
|
||||
}
|
||||
form, err := FillSubmitForm(rows, formItems, "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FillSubmitForm error: %v", err)
|
||||
}
|
||||
if form["qrUuid"] != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("qrUuid = %v", form["qrUuid"])
|
||||
}
|
||||
if _, ok := form["signInstanceWid"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain signInstanceWid")
|
||||
}
|
||||
if _, ok := form["latitude"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain latitude")
|
||||
}
|
||||
if _, ok := form["longitude"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain longitude")
|
||||
}
|
||||
if form["uaIsCpadaily"] != true {
|
||||
t.Fatal("uaIsCpadaily missing")
|
||||
}
|
||||
|
||||
// 缺少二维码 → 明确报错(不再静默生成会假成功的表单)
|
||||
if _, err := FillSubmitForm(rows, nil, "device-1"); err == nil {
|
||||
t.Fatal("missing qrCode should return error")
|
||||
}
|
||||
|
||||
// 普通任务(无 signMode)不受影响:走原逻辑,且不含 qrUuid
|
||||
normalRows := map[string]interface{}{"signInstanceWid": "123"}
|
||||
normalItems := []cpdmodel.CpTaskFormItem{
|
||||
{ColName: "lat", Content: "24.8"},
|
||||
{ColName: "lon", Content: "102.8"},
|
||||
{ColName: "address", Content: "云南大学"},
|
||||
}
|
||||
normalForm, err := FillSubmitForm(normalRows, normalItems, "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("normal FillSubmitForm error: %v", err)
|
||||
}
|
||||
if normalForm["latitude"] != "24.8" || normalForm["signInstanceWid"] != "123" {
|
||||
t.Fatalf("normal form mismatch: %v", normalForm)
|
||||
}
|
||||
if _, ok := normalForm["qrUuid"]; ok {
|
||||
t.Fatal("normal form must not contain qrUuid")
|
||||
}
|
||||
}
|
||||
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成「签到二维码」图片项,不含经纬度项
|
||||
func TestParseScanTaskForm(t *testing.T) {
|
||||
detail := &TaskFormDetail{
|
||||
Rows: map[string]interface{}{"signMode": float64(2)},
|
||||
}
|
||||
items := ParseTaskForm(detail, nil)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("scan form items = %d, want 1: %+v", len(items), items)
|
||||
}
|
||||
if items[0].ColName != "qrCode" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
|
||||
t.Fatalf("unexpected qrCode item: %+v", items[0])
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -12,6 +12,10 @@ type Claims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
// MFA 标记:通过多因子认证通道登录的用户。此类账号第三方会话无法自动续期
|
||||
// (重新登录需人工输入验证码),JWT 有效期与第三方 Cookie 对齐(短期),
|
||||
// 刷新 token 也不会延长会话。
|
||||
MFA bool `json:"mfa,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -64,6 +68,43 @@ func GenerateTokens(userID uint, username, schoolID string) (*TokenPair, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateShortTokens 生成 MFA 通道登录的短期 token 对:
|
||||
// access 与 refresh 有效期均为 ttl 秒(与第三方 Cookie 有效期对齐,当前默认配置 7200 即 2 小时,
|
||||
// 见 config.JwtConfig.MfaAccessExpire),且携带 MFA 标记——刷新 token 时不会延长会话。
|
||||
func GenerateShortTokens(userID uint, username, schoolID string, ttlSeconds int64) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
jwtCfg := config.Global.Jwt
|
||||
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
SchoolID: schoolID,
|
||||
MFA: true,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "cpdaily-new",
|
||||
},
|
||||
}
|
||||
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(jwtCfg.Secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshClaims := accessClaims
|
||||
refreshClaims.Issuer = "cpdaily-new-refresh"
|
||||
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(jwtCfg.Secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: ttlSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -73,6 +114,17 @@ func ValidateToken(tokenString string) (*Claims, error) {
|
||||
return []byte(config.Global.Jwt.Secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
// MFA access token 服务端校验宽限:MFA 学校第三方会话无法自动续期(重登需人工
|
||||
// 输入验证码),已签发 token 无法修改;当 MfaAccessExpire 调整(如 1h → 2h)时,
|
||||
// 对调整前签发的 MFA access token 按「签发时间 + 当前配置 TTL」放行,使存量用户
|
||||
// 无需重新登录、平滑过渡到新有效期。仅放宽 access(issuer=cpdaily-new):
|
||||
// refresh 严格按自身 exp 校验,避免出现刷新窗口导致会话被滑动静默续期,
|
||||
// 违背 MFA 短会话设计。
|
||||
if errors.Is(err, jwt.ErrTokenExpired) &&
|
||||
claims.MFA && claims.Issuer == "cpdaily-new" && claims.IssuedAt != nil &&
|
||||
time.Now().Before(claims.IssuedAt.Add(time.Duration(config.Global.Jwt.MfaAccessExpire)*time.Second)) {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
@@ -86,6 +138,10 @@ func RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
if err != nil {
|
||||
return nil, errors.New("refresh token invalid or expired")
|
||||
}
|
||||
// MFA 通道登录的用户:第三方会话无法自动续期,刷新也不延长有效期
|
||||
if claims.MFA {
|
||||
return GenerateShortTokens(claims.UserID, claims.Username, claims.SchoolID, int64(config.Global.Jwt.MfaAccessExpire))
|
||||
}
|
||||
// 刷新时生成全新 token 对
|
||||
return GenerateTokens(claims.UserID, claims.Username, claims.SchoolID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// 测试配置:MFA TTL 2 小时(与当前生产配置一致)
|
||||
func setupJwtTestConfig() {
|
||||
config.Global.Jwt.Secret = "unit-test-secret"
|
||||
config.Global.Jwt.AccessExpire = 3600
|
||||
config.Global.Jwt.RefreshExpire = 864000
|
||||
config.Global.Jwt.MfaAccessExpire = 7200
|
||||
}
|
||||
|
||||
// signClaims 用手写 Claims 直接签名,模拟不同时期/不同 TTL 签发的 token
|
||||
func signClaims(t *testing.T, secret string, claims Claims) string {
|
||||
t.Helper()
|
||||
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("签名失败: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mfaClaims 构造 MFA token Claims:iat 为签发时间,ttl 为签发时的有效期
|
||||
func mfaClaims(issuer string, iat time.Time, ttl time.Duration) Claims {
|
||||
return Claims{
|
||||
UserID: 1066,
|
||||
Username: "ynu-20251960205",
|
||||
SchoolID: "ynu",
|
||||
MFA: true,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(iat),
|
||||
ExpiresAt: jwt.NewNumericDate(iat.Add(ttl)),
|
||||
Issuer: issuer,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 场景1:1h 配置时代签发的 MFA access token 已过期 30 分钟,但在「签发时间+2h」宽限内 → 放行
|
||||
// (杨蕊宁存量 token 场景:部署 v1.2.39 后无需重新登录)
|
||||
func TestMFAOldAccessTokenWithinGrace(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new", now.Add(-90*time.Minute), 1*time.Hour))
|
||||
claims, err := ValidateToken(tokenStr)
|
||||
if err != nil {
|
||||
t.Fatalf("宽限窗口内(iat+2h)应放行: %v", err)
|
||||
}
|
||||
if claims.UserID != 1066 || !claims.MFA {
|
||||
t.Fatalf("claims 解析异常: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// 场景2:MFA access token 超出「签发时间+2h」宽限 → 拒绝
|
||||
func TestMFAOldAccessTokenBeyondGrace(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new", now.Add(-150*time.Minute), 1*time.Hour))
|
||||
if _, err := ValidateToken(tokenStr); err == nil {
|
||||
t.Fatal("超出宽限窗口的 MFA access token 必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// 场景3:MFA refresh token 已过期 → 拒绝(refresh 不享受宽限,避免滑动续期)
|
||||
func TestMFAOldRefreshTokenNotGraced(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new-refresh", now.Add(-90*time.Minute), 1*time.Hour))
|
||||
if _, err := ValidateToken(tokenStr); err == nil {
|
||||
t.Fatal("MFA refresh token 过期后必须拒绝(不得宽限)")
|
||||
}
|
||||
}
|
||||
|
||||
// 场景4:非 MFA 的普通 access token 过期 → 按原逻辑拒绝
|
||||
func TestNormalExpiredAccessTokenRejected(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: 1,
|
||||
Username: "normal-user",
|
||||
MFA: false,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now.Add(-90 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(-30 * time.Minute)),
|
||||
Issuer: "cpdaily-new",
|
||||
},
|
||||
}
|
||||
if _, err := ValidateToken(signClaims(t, config.Global.Jwt.Secret, claims)); err == nil {
|
||||
t.Fatal("普通用户过期 access token 必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// 场景5:正常有效期内(TTL=2h)的 MFA access token → 放行(常规路径不受影响)
|
||||
func TestMFAAccessTokenValidNormally(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new", now.Add(-10*time.Minute), 2*time.Hour))
|
||||
if _, err := ValidateToken(tokenStr); err != nil {
|
||||
t.Fatalf("有效期内 MFA access token 应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 场景6:错误签名 → 拒绝(宽限不得绕过签名校验)
|
||||
func TestMFAOldAccessTokenWrongSignatureRejected(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
tokenStr := signClaims(t, "wrong-secret", mfaClaims("cpdaily-new", now.Add(-90*time.Minute), 1*time.Hour))
|
||||
if _, err := ValidateToken(tokenStr); err == nil {
|
||||
t.Fatal("错误签名必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// 场景7:MFA access token 缺少签发时间(iat 缺失)→ 拒绝(无法核定宽限窗口)
|
||||
func TestMFAOldAccessTokenWithoutIssuedAtRejected(t *testing.T) {
|
||||
setupJwtTestConfig()
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: 1066,
|
||||
MFA: true,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(-30 * time.Minute)),
|
||||
Issuer: "cpdaily-new",
|
||||
},
|
||||
}
|
||||
if _, err := ValidateToken(signClaims(t, config.Global.Jwt.Secret, claims)); err == nil {
|
||||
t.Fatal("缺少 iat 的过期 MFA access token 必须拒绝")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/smtp"
|
||||
@@ -294,3 +295,49 @@ func NotifySchoolAdapted(contactEmails []string, schoolName string) {
|
||||
<p style="font-size:14px;color:#555">若仍有问题,欢迎在应用内提交工单反馈。</p>`
|
||||
SendMailAsync(contactEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
// NotifySchoolRejected 学校适配申请拒绝通知(发给该学校提交者留下的全部联系邮箱,附拒绝理由)
|
||||
func NotifySchoolRejected(contactEmails []string, schoolName, reason string) {
|
||||
if len(contactEmails) == 0 {
|
||||
slog.Info("学校无联系邮箱,跳过拒绝通知", "school", schoolName)
|
||||
return
|
||||
}
|
||||
escapedReason := html.EscapeString(reason)
|
||||
escapedReason = strings.ReplaceAll(escapedReason, "\n", "<br/>")
|
||||
subject := "【小助手】" + schoolName + " 暂无法适配"
|
||||
inner := `<h2 style="margin:0 0 16px;font-size:17px">很抱歉,` + schoolName + ` 暂无法适配</h2>
|
||||
<p style="font-size:14px;color:#555;line-height:1.8">感谢您的反馈。经管理员核实,该学校暂时无法适配,具体原因如下:</p>
|
||||
<div style="font-size:14px;color:#333;line-height:1.8;background:#f7f8fa;border-radius:8px;padding:12px 16px;margin:12px 0">` + escapedReason + `</div>
|
||||
<p style="font-size:14px;color:#555">请核对账号信息后重新提交,或如有疑问欢迎在应用内提交工单反馈。</p>`
|
||||
SendMailAsync(contactEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
// NotifyNewTicket 新工单提醒(发给管理员)
|
||||
func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, content, createdAt string) {
|
||||
adminEmails := GetAdminEmails()
|
||||
if len(adminEmails) == 0 {
|
||||
slog.Info("未配置管理员邮箱,跳过新工单通知", "ticketId", ticketID)
|
||||
return
|
||||
}
|
||||
// 内容摘要:HTML 转义防注入,换行转 <br>,超长截断
|
||||
escaped := html.EscapeString(content)
|
||||
if len([]rune(escaped)) > 500 {
|
||||
escaped = string([]rune(escaped)[:500]) + "……"
|
||||
}
|
||||
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
|
||||
displayName := realName
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
subject := "【小助手】新的工单:" + title
|
||||
inner := `<h2 style="margin:0 0 16px;font-size:17px">收到新的工单反馈</h2>
|
||||
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
|
||||
<tr><td style="padding-right:16px;color:#888;vertical-align:top">标题</td><td><b>` + html.EscapeString(title) + `</b></td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888;vertical-align:top">内容</td><td style="max-width:420px">` + escaped + `</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">提交时间</td><td>` + html.EscapeString(createdAt) + `</td></tr>
|
||||
</table>
|
||||
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
|
||||
SendMailAsync(adminEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Redis 全局客户端(验证码存储/限流等)。启动时初始化,连接失败不阻断进程启动,
|
||||
// go-redis 会在后续请求时自动重连。
|
||||
var Redis *redis.Client
|
||||
|
||||
// InitRedis 初始化 Redis 客户端并做一次连通性探测
|
||||
func InitRedis() {
|
||||
cfg := config.Global.Redis
|
||||
if cfg.Host == "" {
|
||||
slog.Warn("未配置 [redis],验证码相关功能将不可用")
|
||||
return
|
||||
}
|
||||
Redis = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.Db,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := Redis.Ping(ctx).Err(); err != nil {
|
||||
slog.Error("Redis 连接失败(验证码功能暂不可用,请检查配置)", "addr", cfg.Host, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// ClientIP 提取客户端真实 IP。
|
||||
// 生产环境经 nginx 反代,nginx 已配置 proxy_set_header X-Real-IP $remote_addr
|
||||
// 和 X-Forwarded-For $proxy_add_x_forwarded_for,因此优先取 X-Real-IP(nginx 直接写入,
|
||||
// 最可信);其次取 X-Forwarded-For 第一段(多层代理链上最初的客户端 IP);
|
||||
// 都没有时兜底 Fiber 默认的 RemoteAddr(直连场景,如本地开发)。
|
||||
func ClientIP(c *fiber.Ctx) string {
|
||||
if rip := strings.TrimSpace(c.Get("X-Real-IP")); rip != "" {
|
||||
return rip
|
||||
}
|
||||
if xff := strings.TrimSpace(c.Get("X-Forwarded-For")); xff != "" {
|
||||
if idx := strings.IndexByte(xff, ','); idx >= 0 {
|
||||
xff = xff[:idx]
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
return c.IP()
|
||||
}
|
||||
@@ -6,6 +6,19 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// MFA 多因子认证专用业务码:HTTP 200 返回,前端按 code 弹出验证码弹窗而非错误弹窗
|
||||
const (
|
||||
CodeMFARequired = 4281 // 登录触发多因子认证,Data 含 needMFA/mfaToken/expiresIn/authMethods(可用验证方式列表)
|
||||
CodeMFAVerifyFailed = 4282 // MFA 验证码错误或验证失败(弹窗保留可重试)
|
||||
CodeMFAVerifySessionExpired = 4283 // MFA 验证会话不存在或已过期(前端应关闭弹窗引导重新登录)
|
||||
CodeMFASendCodeFailed = 4284 // MFA 验证码发送失败(弹窗保留在选择界面,可重试或更换验证方式)
|
||||
)
|
||||
|
||||
// RespondWithCode 以 HTTP 200 返回指定业务码(MFA 等需要前端精确分支处理的状态)
|
||||
func RespondWithCode(c *fiber.Ctx, code int, msg string, data interface{}) error {
|
||||
return c.JSON(Response{Code: code, Message: msg, Data: data})
|
||||
}
|
||||
|
||||
// FormatFloat 格式化浮点数为字符串保留2位小数
|
||||
func FormatFloat(f float64) string {
|
||||
return fmt.Sprintf("%.2f", f)
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8884:8093"
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
- /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro
|
||||
networks:
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5f5f5" />
|
||||
<title>小助手</title>
|
||||
<script>
|
||||
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
|
||||
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
|
||||
;(function () {
|
||||
var h = new Date().getHours()
|
||||
if (h >= 18 || h < 6) {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -28,7 +28,7 @@ watch(
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
|
||||
@@ -137,6 +137,11 @@ export interface CDKCode {
|
||||
price: number
|
||||
costPrice: number
|
||||
agentId: number
|
||||
maxRuns: number
|
||||
/** CDK类型:count=次数卡 / vip=VIP时长卡 */
|
||||
type: 'count' | 'vip'
|
||||
/** VIP 时长天数(type=vip 有效:7=周卡 30=月卡) */
|
||||
vipDays: number
|
||||
status: string
|
||||
userId: number | null
|
||||
usedAt: string | null
|
||||
@@ -144,6 +149,28 @@ export interface CDKCode {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CDKSummary {
|
||||
total: number
|
||||
unused: number
|
||||
used: number
|
||||
disabled: number
|
||||
}
|
||||
|
||||
export interface CDKListResult {
|
||||
total: number
|
||||
items: CDKCode[]
|
||||
page: number
|
||||
pageSize: number
|
||||
summary: CDKSummary
|
||||
}
|
||||
|
||||
export interface CDKListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface GenerateCDKResult {
|
||||
count: number
|
||||
price: number
|
||||
@@ -152,19 +179,39 @@ export interface GenerateCDKResult {
|
||||
cdkCodes: CDKCode[]
|
||||
}
|
||||
|
||||
/** 生成CDK */
|
||||
export function generateCDK(count: number = 1, maxRuns: number = 1) {
|
||||
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns })
|
||||
/** 生成CDK(type: count=次数 / vip=VIP时长,vipDays 默认 30 天) */
|
||||
export function generateCDK(
|
||||
count: number = 1,
|
||||
maxRuns: number = 1,
|
||||
type: 'count' | 'vip' = 'count',
|
||||
vipDays: number = 30
|
||||
) {
|
||||
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns, type, vipDays })
|
||||
}
|
||||
|
||||
/** 获取自己生成的CDK列表 */
|
||||
export function getMyCDKs() {
|
||||
return api.get<{ data: CDKCode[] }>('/admin/cdks')
|
||||
export function getMyCDKs(params: CDKListParams = {}) {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('page', String(params.page || 1))
|
||||
qs.set('pageSize', String(params.pageSize || 20))
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
if (params.status) qs.set('status', params.status)
|
||||
return api.get<{ data: CDKListResult }>(`/admin/cdks?${qs.toString()}`)
|
||||
}
|
||||
|
||||
/** 获取所有CDK列表(admin only) */
|
||||
export function getAllCDKs() {
|
||||
return api.get<{ data: CDKCode[] }>('/admin/all-cdks')
|
||||
export function getAllCDKs(params: CDKListParams = {}) {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('page', String(params.page || 1))
|
||||
qs.set('pageSize', String(params.pageSize || 20))
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
if (params.status) qs.set('status', params.status)
|
||||
return api.get<{ data: CDKListResult }>(`/admin/all-cdks?${qs.toString()}`)
|
||||
}
|
||||
|
||||
/** 禁用/恢复CDK */
|
||||
export function updateCDKStatus(id: number, action: 'disable' | 'enable') {
|
||||
return api.put(`/admin/cdks/${id}/status`, { action })
|
||||
}
|
||||
|
||||
// ======== 系统设置(admin only) ========
|
||||
@@ -270,6 +317,8 @@ export interface UserListItem {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
taskCount: number
|
||||
status: string
|
||||
createdAt: string
|
||||
@@ -294,6 +343,8 @@ export interface UserDetail {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
status: string
|
||||
disabledReason: string
|
||||
taskCount: number
|
||||
@@ -321,3 +372,43 @@ export function getUserDetail(id: number) {
|
||||
export function updateUserStatus(id: number, status: 'active' | 'disabled', disabledReason = '') {
|
||||
return api.put(`/admin/users/${id}/status`, { status, disabledReason })
|
||||
}
|
||||
|
||||
// ======== 任务执行日志(admin only) ========
|
||||
|
||||
export interface AdminTaskLogItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
taskType: string // sign / sleep_check
|
||||
title: string
|
||||
realTitle: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminTaskLogResult {
|
||||
total: number
|
||||
items: AdminTaskLogItem[]
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 获取任务执行日志列表(默认当天) */
|
||||
export function getAdminTaskLogs(params: {
|
||||
date?: string
|
||||
status?: 'all' | 'success' | 'fail'
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.date) qs.set('date', params.date)
|
||||
qs.set('status', params.status || 'all')
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
qs.set('page', String(params.page || 1))
|
||||
qs.set('pageSize', String(params.pageSize || 20))
|
||||
return api.get<{ data: AdminTaskLogResult }>(`/admin/task-logs?${qs.toString()}`)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface LoginParams {
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
firstLogin?: boolean
|
||||
token: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
@@ -55,9 +56,25 @@ export interface UserInfo {
|
||||
mobileCode: string
|
||||
mobileType: string
|
||||
signCount: number
|
||||
/** VIP 到期时间(null=从未开通) */
|
||||
vipExpireAt: string | null
|
||||
inviteCode: string
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
/** 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导) */
|
||||
guideVersion: string
|
||||
}
|
||||
|
||||
/** 我的邀请信息(含奖励规则,随系统配置变化) */
|
||||
export interface MyInviteInfo {
|
||||
inviteCode: string
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
canRedeem: boolean
|
||||
/** 邀请人每邀请 1 人获得的奖励积分(系统配置) */
|
||||
inviterRewardPerInvite: number
|
||||
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
|
||||
inviteeRewardPerInvite: number
|
||||
}
|
||||
|
||||
/** 搜索学校 */
|
||||
@@ -77,6 +94,51 @@ export function login(params: LoginParams) {
|
||||
return api.post<{ data: LoginResult }>('/auth/login', params)
|
||||
}
|
||||
|
||||
/** MFA 可用验证方式(登录返回 4281 后由后端给出) */
|
||||
export interface MFAAuthMethod {
|
||||
/** reAuthType 编码:3=短信 4=企业微信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号 */
|
||||
type: string
|
||||
/** 展示名(如「今日校园验证码」) */
|
||||
name: string
|
||||
/** 是否为页面默认选中项 */
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
/** MFA 发送验证码参数(用户选定验证方式后调用) */
|
||||
export interface MFASendCodeParams {
|
||||
mfaToken: string
|
||||
reAuthType: string
|
||||
}
|
||||
|
||||
/** MFA 验证码发送结果 */
|
||||
export interface MFASendCodeResult {
|
||||
/** 收码目标(手机号等,可能带掩码) */
|
||||
mobile?: string
|
||||
/** 可再次发送的倒计时秒数 */
|
||||
codeTime?: number
|
||||
}
|
||||
|
||||
/** 发送多因子认证验证码(用户选择验证方式后调用)
|
||||
* 业务码约定(HTTP 200):0=发送成功进入输入阶段;4283=会话过期需重新登录;4284=发送失败可重试 */
|
||||
export function sendMFACode(params: MFASendCodeParams) {
|
||||
return api.post<{ code: number; message: string; data?: MFASendCodeResult }>(
|
||||
'/auth/login/mfa/send',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
/** MFA 验证码提交参数 */
|
||||
export interface MFAVerifyParams {
|
||||
mfaToken: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/** 提交多因子认证验证码(登录返回 code 4281 后调用)
|
||||
* 业务码约定(HTTP 200):0=成功;4281=需MFA;4282=验证码错误可重试;4283=会话过期需重新登录 */
|
||||
export function verifyMFA(params: MFAVerifyParams) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
|
||||
}
|
||||
|
||||
/** 刷新 token */
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
@@ -104,9 +166,28 @@ export function getDeviceStatus(token: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK,一次性消耗全部次数) */
|
||||
/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */
|
||||
export function randomBindDevice() {
|
||||
return api.post<{ data: { deviceCode: string } }>('/device/random-bind')
|
||||
}
|
||||
|
||||
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK;次数 CDK 加次数 / VIP CDK 延长 VIP) */
|
||||
export function redeemCDK(code: string, captchaId: string, captchaCode: string) {
|
||||
return api.post<{ data: { totalRuns: number } }>('/cdk/redeem', { code, captchaId, captchaCode })
|
||||
return api.post<{
|
||||
data: {
|
||||
type: 'count' | 'vip'
|
||||
totalRuns?: number
|
||||
vipDays?: number
|
||||
vipExpireAt?: string
|
||||
}
|
||||
}>('/cdk/redeem', { code, captchaId, captchaCode })
|
||||
}
|
||||
|
||||
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
|
||||
export function getVipInfo() {
|
||||
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
|
||||
'/cdk/vip-info'
|
||||
)
|
||||
}
|
||||
|
||||
// ======== 签到任务相关 ========
|
||||
@@ -258,15 +339,20 @@ export function deleteTaskLog(id: number) {
|
||||
|
||||
/** 获取/生成邀请码 */
|
||||
export function getMyInvite() {
|
||||
return api.post<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/generate-invite')
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
|
||||
}
|
||||
|
||||
/** 查询邀请信息 */
|
||||
export function getMyInviteInfo() {
|
||||
return api.get<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/my-invite')
|
||||
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
|
||||
}
|
||||
|
||||
/** 兑换邀请奖励 */
|
||||
export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
|
||||
export function completeGuide() {
|
||||
return api.post('/auth/guide/complete')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import api from './index'
|
||||
|
||||
// ======== 类型定义 ========
|
||||
|
||||
/** 学校规定的销假位置(详情 reportPlaces) */
|
||||
export interface LeaveReportPlace {
|
||||
wid: string
|
||||
address: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
/** 允许范围半径(米):销假定位需在该位置半径内 */
|
||||
radius: string
|
||||
/** 位置设置人 */
|
||||
counName: string
|
||||
}
|
||||
|
||||
/** 待销假记录(含规定销假位置与销假开关) */
|
||||
export interface LeaveItem {
|
||||
id: string
|
||||
leaveType: string
|
||||
reason: string
|
||||
createTime: string
|
||||
actEndTime: string
|
||||
status: string
|
||||
/** 学校规定的销假位置列表;为空时需用户手动输入 */
|
||||
reportPlaces: LeaveReportPlace[]
|
||||
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
|
||||
reportApplyFlow: boolean
|
||||
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
|
||||
enableAttach: boolean
|
||||
}
|
||||
|
||||
/** 销假可选审批人 */
|
||||
export interface LeaveApprover {
|
||||
userWid: string
|
||||
userId: string
|
||||
userName: string
|
||||
}
|
||||
|
||||
/** 销假执行参数 */
|
||||
export interface ExecuteLeaveParams {
|
||||
leaveId: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
address: string
|
||||
imageUrl?: string
|
||||
/** reportApplyFlow=true 时必填 */
|
||||
approverWid?: string
|
||||
}
|
||||
|
||||
/** 销假日志 */
|
||||
export interface LeaveLog {
|
||||
id: number
|
||||
userId: number
|
||||
leaveId: string
|
||||
title: string
|
||||
schoolName: string
|
||||
/** report=销假 / apply=请假 */
|
||||
action: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 请假类型(已合并 queryLeaveTypeCfg 的权威开关) */
|
||||
export interface LeaveType {
|
||||
code: number
|
||||
name: string
|
||||
/** 1=必须离校 */
|
||||
mustOut: number
|
||||
/** 1=需要选择审批人 */
|
||||
needApproval: number
|
||||
/** 1=允许上传图片附件 */
|
||||
enableAttach: number
|
||||
/** 请假原因最少字数 */
|
||||
reasonWordLimit: number
|
||||
/** 附件数量上限 */
|
||||
attachmentNumberLimit: number
|
||||
/** 1=当前类型不可用 */
|
||||
disableTime: number
|
||||
/** 单次最长可请假天数(0=不限) */
|
||||
limitDay: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
export interface ExecuteLeaveApplyParams {
|
||||
leaveType: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
leaveReason: string
|
||||
outStatus: boolean
|
||||
urgencyMobile?: string
|
||||
address?: string
|
||||
detailAddress?: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
locationAddr: string
|
||||
imageUrl?: string
|
||||
approverWid?: string
|
||||
approverId?: string
|
||||
approverName?: string
|
||||
}
|
||||
|
||||
// ======== API ========
|
||||
|
||||
/** 获取今日校园待销假列表(实时请求,附带规定销假位置与开关) */
|
||||
export function getPendingLeaves() {
|
||||
return api.get<{ data: LeaveItem[] }>('/leave/pending-list')
|
||||
}
|
||||
|
||||
/** 获取销假可选审批人(reportApplyFlow=true 的请假类型使用) */
|
||||
export function getLeaveApprovers(leaveId: string) {
|
||||
return api.post<{ data: LeaveApprover[] }>('/leave/approvers', { leaveId })
|
||||
}
|
||||
|
||||
/** 获取请假类型列表(含权威开关:needApproval/enableAttach/reasonWordLimit 等) */
|
||||
export function getLeaveTypes() {
|
||||
return api.get<{ data: LeaveType[] }>('/leave/types')
|
||||
}
|
||||
|
||||
/** 获取请假可选审批人(needApproval=1 的类型,需先选类型与起止时间) */
|
||||
export function getApplyApprovers(params: { leaveType: number; startTime: string; endTime: string }) {
|
||||
return api.post<{ data: LeaveApprover[] }>('/leave/apply-approvers', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行请假申请(POST + SSE 流式事件,与销假执行体验一致)。
|
||||
* 事件流:connecting → submitting → success / task_error
|
||||
*/
|
||||
export function executeLeaveApplySSE(params: ExecuteLeaveApplyParams, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onSubmitting?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}): AbortController {
|
||||
return leaveSSE('/leave/apply', params, callbacks)
|
||||
}
|
||||
|
||||
/** 获取销假日志列表 */
|
||||
export function getLeaveLogs() {
|
||||
return api.get<{ data: LeaveLog[] }>('/leave/logs')
|
||||
}
|
||||
|
||||
/** 删除销假日志 */
|
||||
export function deleteLeaveLog(id: number) {
|
||||
return api.delete(`/leave/logs/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行销假(POST + SSE 流式事件,与打卡执行体验一致)。
|
||||
* 事件流:connecting → fetching_detail → submitting → success / task_error
|
||||
* 返回 AbortController,可用于中断(如关闭弹窗时)。
|
||||
*/
|
||||
export function executeLeaveSSE(params: ExecuteLeaveParams, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onFetchingDetail?: () => void
|
||||
onSubmitting?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}): AbortController {
|
||||
return leaveSSE('/leave/execute', params, callbacks)
|
||||
}
|
||||
|
||||
/**
|
||||
* 请假/销假共用的 SSE 执行封装。
|
||||
* 事件流:connecting → (fetching_detail) → submitting → success / task_error
|
||||
*/
|
||||
function leaveSSE(path: string, params: object, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onFetchingDetail?: () => void
|
||||
onSubmitting?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
;(async () => {
|
||||
let finished = false
|
||||
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
fn?.(...args)
|
||||
}
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = '连接失败,请检查网络'
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j?.message) msg = j.message
|
||||
} catch { /* 保留默认提示 */ }
|
||||
finish(callbacks.onError, msg, { message: msg })
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
|
||||
// 按空行切分 SSE 事件块(event: xxx\ndata: xxx\n\n)
|
||||
let sep: number
|
||||
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||
const chunk = buf.slice(0, sep)
|
||||
buf = buf.slice(sep + 2)
|
||||
|
||||
let event = 'message'
|
||||
const dataLines: string[] = []
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
}
|
||||
let data: any = {}
|
||||
const dataRaw = dataLines.join('\n')
|
||||
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
|
||||
|
||||
switch (event) {
|
||||
case 'connecting':
|
||||
callbacks.onConnecting?.()
|
||||
break
|
||||
case 'fetching_detail':
|
||||
callbacks.onFetchingDetail?.()
|
||||
break
|
||||
case 'submitting':
|
||||
callbacks.onSubmitting?.()
|
||||
break
|
||||
case 'success':
|
||||
finish(callbacks.onSuccess, data)
|
||||
break
|
||||
case 'task_error':
|
||||
finish(callbacks.onError, data?.message || '执行失败', data)
|
||||
break
|
||||
}
|
||||
if (finished) break
|
||||
}
|
||||
if (finished) break
|
||||
}
|
||||
|
||||
// 流结束但未收到任何结果事件:视为异常中断
|
||||
finish(callbacks.onError, '连接中断,请重试')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return // 主动取消,不回调
|
||||
finish(callbacks.onError, e?.message || '连接失败,请检查网络')
|
||||
}
|
||||
})()
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
|
||||
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: #f5f5f5;
|
||||
--color-foreground: #1a1a1a;
|
||||
@@ -60,3 +64,76 @@ body {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
|
||||
*
|
||||
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
|
||||
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
|
||||
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
|
||||
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
|
||||
* ============================================================ */
|
||||
.dark {
|
||||
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
|
||||
--color-white: #1e1e2a;
|
||||
--color-gray-50: #232331;
|
||||
--color-gray-100: #2a2a3a;
|
||||
--color-gray-200: #36364a;
|
||||
--color-gray-300: #474763;
|
||||
--color-gray-400: #8f8fab;
|
||||
--color-gray-500: #a5a5c3;
|
||||
--color-gray-600: #bcbcd6;
|
||||
--color-gray-700: #d4d4e6;
|
||||
--color-gray-800: #e5e5f0;
|
||||
--color-gray-900: #ededf6;
|
||||
--color-gray-950: #f4f4fa;
|
||||
/* —— 彩色浅底 → 对应暗色调 —— */
|
||||
--color-blue-50: #1c2a4d;
|
||||
--color-blue-100: #243660;
|
||||
--color-red-50: #3d1c28;
|
||||
--color-red-100: #522534;
|
||||
--color-rose-50: #3a1c2b;
|
||||
--color-rose-100: #4d2539;
|
||||
--color-green-50: #173224;
|
||||
--color-green-100: #1f4430;
|
||||
--color-amber-50: #3a2f16;
|
||||
--color-amber-100: #4d3f1e;
|
||||
--color-purple-100: #322552;
|
||||
/* —— 语义 token(ui/ 组件与语义类使用)—— */
|
||||
--color-background: #15151f;
|
||||
--color-foreground: #ededf6;
|
||||
--color-card: #1e1e2a;
|
||||
--color-card-foreground: #ededf6;
|
||||
--color-secondary: #262636;
|
||||
--color-secondary-foreground: #d4d4e6;
|
||||
--color-muted: #262636;
|
||||
--color-muted-foreground: #9c9cb8;
|
||||
--color-accent: #262636;
|
||||
--color-accent-foreground: #d4d4e6;
|
||||
--color-border: #33334a;
|
||||
--color-input: #3a3a55;
|
||||
--color-ring: #3b82f6;
|
||||
}
|
||||
|
||||
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
|
||||
.dark .text-white { color: #fff; }
|
||||
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
|
||||
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
|
||||
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
|
||||
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
|
||||
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
|
||||
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
|
||||
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
|
||||
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
|
||||
|
||||
/* —— 全局容器暗色 —— */
|
||||
.dark body {
|
||||
background-color: #0d0d14;
|
||||
}
|
||||
.dark #app {
|
||||
background-color: #15151f;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.dark #app.admin-mode {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { X, ChevronLeft, ChevronRight, Sparkles, Play } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
/** 邀请人每邀请 1 人奖励积分(系统配置,可能尚未加载为 null) */
|
||||
inviterRewardPerInvite?: number | null
|
||||
/** 被邀请人注册后奖励积分(系统配置,可能尚未加载为 null) */
|
||||
inviteeRewardPerInvite?: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
/** 请求切换到指定 tab(目标元素在另一页面时) */
|
||||
(e: 'goto', tab: 'home' | 'profile'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const step = ref(0)
|
||||
const curTarget = ref<Element | null>(null)
|
||||
const holeRect = ref<{ top: number; left: number; width: number; height: number; r: number } | null>(null)
|
||||
const bubblePos = ref<'top' | 'bottom' | 'center'>('center')
|
||||
const waitingTarget = ref(false)
|
||||
const bubbleEl = ref<HTMLElement | null>(null)
|
||||
let locateSeq = 0
|
||||
let rafId = 0
|
||||
let pollTimer: number | undefined
|
||||
let lastFlipAt = 0
|
||||
let bodyObs: ResizeObserver | null = null
|
||||
let targetObs: ResizeObserver | null = null
|
||||
let bubbleObs: ResizeObserver | null = null
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
function fmtReward(v: number | null | undefined): string | null {
|
||||
if (v === null || v === undefined) return null
|
||||
const n = Math.round(v * 10) / 10
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
interface GuideStep {
|
||||
tab: 'home' | 'profile'
|
||||
title: string
|
||||
/** 目标元素候选(按序取第一个存在的) */
|
||||
targets: string[]
|
||||
/** 不同目标命中时的说明文案 */
|
||||
descByKind: (kind: string | null) => string
|
||||
}
|
||||
|
||||
const steps: GuideStep[] = [
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '领取签到次数',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
targets: ['[data-guide="home-create-task"]'],
|
||||
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '执行签到',
|
||||
targets: ['[data-guide="home-task-exec"]', '[data-guide="home-task-empty"]', '[data-guide="home-task-list"]'],
|
||||
descByKind: (kind) =>
|
||||
kind === 'home-task-exec'
|
||||
? '任务创建后显示在下方列表。点任务右侧的「执行」按钮,即可完成今天的签到,之后每天无需再手动操作。'
|
||||
: kind === 'home-task-empty'
|
||||
? '你还没有真实任务,下面先展示一张示例任务卡。保存真实任务后它会出现在这里,点卡片右侧「执行」按钮即可完成每日签到。'
|
||||
: '任务创建后显示在这个区域。点任务右侧的「执行」按钮即可完成每日签到,无需手动操作。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '次数不够?邀请好友赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
|
||||
descByKind: (kind) => {
|
||||
const inviter = fmtReward(props.inviterRewardPerInvite)
|
||||
const invitee = fmtReward(props.inviteeRewardPerInvite)
|
||||
if (kind === 'profile-invite-generate') {
|
||||
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
|
||||
}
|
||||
if (inviter !== null && invitee !== null) {
|
||||
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
|
||||
}
|
||||
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
|
||||
},
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
const current = computed(() => steps[step.value])
|
||||
const isLast = computed(() => step.value === steps.length - 1)
|
||||
|
||||
// 当前命中的目标类别(用于生成对应文案)
|
||||
const resolvedKind = computed(() => {
|
||||
const el = curTarget.value
|
||||
if (!el) return null
|
||||
for (const t of current.value.targets) {
|
||||
if (el.matches(t)) return t.replace('[data-guide="', '').replace('"]', '')
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const guideDesc = computed(() => {
|
||||
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
const el = curTarget.value
|
||||
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
|
||||
}
|
||||
return current.value.descByKind(resolvedKind.value)
|
||||
})
|
||||
|
||||
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
|
||||
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
const mockMode = computed(() => {
|
||||
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
|
||||
if (resolvedKind.value === 'home-task-empty') return true
|
||||
const el = curTarget.value
|
||||
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
|
||||
})
|
||||
// 示例卡锚定在空态区(空态元素 rect);不跟随升级后的按钮 rect,避免卡片随高亮框错位
|
||||
const mockAnchor = ref<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
|
||||
function syncMockAnchor() {
|
||||
const anchor = document.querySelector('[data-guide="home-task-empty"]')
|
||||
if (anchor) {
|
||||
const r = anchor.getBoundingClientRect()
|
||||
mockAnchor.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||
}
|
||||
}
|
||||
watch(mockMode, (v) => {
|
||||
if (v) syncMockAnchor()
|
||||
else mockAnchor.value = null
|
||||
})
|
||||
|
||||
const mockCardStyle = computed(() => {
|
||||
const r = mockAnchor.value
|
||||
if (!r) return null
|
||||
return {
|
||||
top: `${Math.max(0, Math.round(r.top + (r.height - 76) / 2))}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
}
|
||||
})
|
||||
|
||||
let lastLocateAt = 0
|
||||
|
||||
/* ================= 定位与高亮 ================= */
|
||||
|
||||
// 方位决策(measure 与 ensure 共用同一函数,避免两侧交替乒乓):
|
||||
// 优先下方(移动端阅读流自然向下展开,也符合"上方放不下就放下方"的预期);
|
||||
// 下方放不下才放上方;两侧都不能完整容纳时选"溢出更少"的一侧
|
||||
function choosePos(r: { top: number; bottom: number }, bH: number, vh: number): 'top' | 'bottom' {
|
||||
const gapTop = r.top - 12 // 目标上方可用高度(气泡底距目标顶 12px 间隙)
|
||||
const gapBottom = vh - r.bottom - 12 // 目标下方可用高度
|
||||
const need = Math.max(240, bH > 0 ? bH + 24 : 260)
|
||||
if (gapBottom >= need) return 'bottom'
|
||||
if (gapTop >= need) return 'top'
|
||||
// 两侧都不足:比较溢出量(气泡被视口裁剪的部分),选溢出少的一侧
|
||||
const topLost = Math.max(0, bH - gapTop)
|
||||
const bottomLost = Math.max(0, bH - gapBottom)
|
||||
return bottomLost <= topLost ? 'bottom' : 'top'
|
||||
}
|
||||
|
||||
function measure(el: Element) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const cs = getComputedStyle(el)
|
||||
const br = parseFloat(cs.borderRadius) || 0
|
||||
const vh = window.innerHeight
|
||||
// 目标完全不可见(被遮罩同层遮挡或滚动出屏)时按中心兜底
|
||||
const visibleH = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0))
|
||||
if (r.width <= 0 || r.height <= 0 || visibleH <= 0) {
|
||||
if (holeRect.value || bubblePos.value !== 'center') {
|
||||
holeRect.value = null
|
||||
bubblePos.value = 'center'
|
||||
}
|
||||
return
|
||||
}
|
||||
// 位置/尺寸变化超过阈值才更新(异步数据/图片导致布局高度变化后自动纠正错位)
|
||||
const next = {
|
||||
top: r.top,
|
||||
left: r.left,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
r: Math.max(0, Math.min(br, r.width / 2, r.height / 2, 18)),
|
||||
}
|
||||
const old = holeRect.value
|
||||
const moved =
|
||||
!old ||
|
||||
Math.abs(old.top - next.top) > 1 ||
|
||||
Math.abs(old.left - next.left) > 1 ||
|
||||
Math.abs(old.width - next.width) > 1 ||
|
||||
Math.abs(old.height - next.height) > 1 ||
|
||||
Math.abs(old.r - next.r) > 1
|
||||
if (moved) holeRect.value = next
|
||||
|
||||
// 用气泡真实高度(内容/文案变化后实时跟随)决定放目标上方还是下方,始终与高亮框保留间隙
|
||||
// need 采用保守估算:保证整颗气泡完整落在目标一侧,杜绝"放上方但上方放不下溢出到视口外"
|
||||
const bH = bubbleEl.value?.offsetHeight || 0
|
||||
const pos = choosePos({ top: r.top, bottom: r.bottom }, bH, vh)
|
||||
if (pos !== bubblePos.value) bubblePos.value = pos
|
||||
}
|
||||
|
||||
// 气泡渲染后自检:若气泡与真实目标元素相交、或被视口裁掉过多(布局错位/文案高度突变/
|
||||
// 测量竞态等未及修正的场景),按真实尺寸重新走同一方位决策绕开目标
|
||||
function ensureBubbleNotOverlapping(el: Element) {
|
||||
requestAnimationFrame(() => {
|
||||
const b = bubbleEl.value
|
||||
if (!props.show || !b) return
|
||||
const br = b.getBoundingClientRect()
|
||||
const tr = el.getBoundingClientRect()
|
||||
const vh = window.innerHeight
|
||||
const overlapped =
|
||||
br.right > tr.left + 2 &&
|
||||
br.left < tr.right - 2 &&
|
||||
br.bottom > tr.top + 2 &&
|
||||
br.top < tr.bottom - 2
|
||||
// top 模式顶部被视口裁切 / bottom 模式底部被裁切 / center 兜底样式压住了目标
|
||||
const topClipped = bubblePos.value === 'top' && br.top < 4
|
||||
const bottomClipped = bubblePos.value === 'bottom' && br.bottom > vh - 4
|
||||
if (!overlapped && !topClipped && !bottomClipped) return
|
||||
// 400ms 防抖:决策后让 measure/轮询先消化,避免高频翻转
|
||||
const now = Date.now()
|
||||
if (now - lastFlipAt < 400) return
|
||||
lastFlipAt = now
|
||||
if (bubblePos.value === 'center') {
|
||||
// 等待/兜底样式与目标相交(目标已渲染):立即重新定位
|
||||
void locate()
|
||||
return
|
||||
}
|
||||
// 与 measure 相同的决策函数:相交/越界时按真实气泡高度重选方位,不会乒乓
|
||||
const bH = b.offsetHeight || 240
|
||||
const desired = choosePos({ top: tr.top, bottom: tr.bottom }, bH, vh)
|
||||
if (desired !== bubblePos.value) bubblePos.value = desired
|
||||
})
|
||||
}
|
||||
|
||||
// 等待平滑滚动结束再测量(滚动中途测到的位置是错的,会导致气泡方位误判)
|
||||
function waitScrollSettled(timeout: number): Promise<void> {
|
||||
if (timeout <= 0) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
let t = 0
|
||||
const onScroll = () => {
|
||||
window.clearTimeout(t)
|
||||
t = window.setTimeout(cleanup, 120)
|
||||
}
|
||||
const cleanup = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
window.clearTimeout(t)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
resolve()
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
t = window.setTimeout(cleanup, 120) // 120ms 无滚动即视为稳定
|
||||
window.setTimeout(cleanup, timeout) // 最长等待兜底
|
||||
})
|
||||
}
|
||||
|
||||
async function locate() {
|
||||
const seq = ++locateSeq
|
||||
lastLocateAt = Date.now()
|
||||
const s = current.value
|
||||
const crossTab = route.name !== s.tab
|
||||
const prevEl = curTarget.value
|
||||
waitingTarget.value = crossTab
|
||||
curTarget.value = null
|
||||
if (crossTab) {
|
||||
// 跨 tab:旧目标所在页面即将切换,清空高亮避免残影
|
||||
holeRect.value = null
|
||||
emit('goto', s.tab)
|
||||
}
|
||||
// 同 tab 切换步骤时保留旧高亮作为过渡锚点(找到新目标后平滑滑移过去),避免闪黑
|
||||
let el: Element | null = null
|
||||
for (let k = 0; k < 40; k++) {
|
||||
if (seq !== locateSeq) return // 期间步骤已切换
|
||||
if (!el) {
|
||||
for (const sel of s.targets) {
|
||||
el = document.querySelector(sel)
|
||||
if (el) break
|
||||
}
|
||||
}
|
||||
if (el) break
|
||||
await sleep(250)
|
||||
}
|
||||
if (seq !== locateSeq) return
|
||||
waitingTarget.value = false
|
||||
if (!el) {
|
||||
// 找不到任何目标:居中气泡引导
|
||||
curTarget.value = null
|
||||
holeRect.value = null
|
||||
bubblePos.value = 'center'
|
||||
return
|
||||
}
|
||||
curTarget.value = el
|
||||
const moved = !prevEl || prevEl !== el
|
||||
// 找到目标后先立即测量一次:即使后续需要滚动/等待,气泡也已按正确方位
|
||||
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
|
||||
measure(el)
|
||||
try {
|
||||
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
|
||||
} catch {
|
||||
// ignore(个别浏览器不支持 options)
|
||||
}
|
||||
await waitScrollSettled(moved ? 800 : 0)
|
||||
if (seq !== locateSeq) return
|
||||
measure(el)
|
||||
ensureBubbleNotOverlapping(el)
|
||||
}
|
||||
|
||||
function setStep(i: number) {
|
||||
step.value = Math.max(0, Math.min(steps.length - 1, i))
|
||||
locateSeq++
|
||||
void locate()
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (step.value > 0) setStep(step.value - 1)
|
||||
}
|
||||
function next() {
|
||||
if (step.value < steps.length - 1) setStep(step.value + 1)
|
||||
else emit('close')
|
||||
}
|
||||
function skip() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 目标随页面滚动/窗口/布局(异步数据渲染、图片加载)变化时重新测量校正
|
||||
function scheduleMeasure() {
|
||||
if (!props.show) return
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const el = curTarget.value
|
||||
if (el && el.isConnected && document.contains(el)) {
|
||||
measure(el)
|
||||
ensureBubbleNotOverlapping(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 引导展示期间低频轮询校正:兜底 ResizeObserver 覆盖不到的变化
|
||||
// (内容在 overflow 滚动容器内变化时 body 尺寸不变、目标被懒加载/接口数据渲染等)
|
||||
function startLayoutPolling() {
|
||||
stopLayoutPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (!props.show) return
|
||||
// mock 示例卡锚点持续校正(布局/异步数据导致空态区位置变化时卡片跟随)
|
||||
if (mockMode.value) syncMockAnchor()
|
||||
const el = curTarget.value
|
||||
if (el && el.isConnected && document.contains(el)) {
|
||||
measure(el)
|
||||
// 目标升级:异步数据渲染出更高优先级的 v-if 目标(生成→复制 按钮、行→兑换 按钮)时重定位
|
||||
const better = current.value.targets.find((sel) => document.querySelector(sel))
|
||||
if (better && !el.matches(better) && Date.now() - lastLocateAt > 1000) {
|
||||
void locate()
|
||||
}
|
||||
} else if (el) {
|
||||
// 目标已从 DOM 消失(v-if 切换),重新定位到当前存在的最优目标
|
||||
void locate()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
function stopLayoutPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ResizeObserver:body(页面整体布局变化)/ 高亮目标自身(尺寸变化)/ 气泡(文案高度变化)→ 触发重定位
|
||||
function observeResize() {
|
||||
try {
|
||||
bodyObs?.disconnect()
|
||||
targetObs?.disconnect()
|
||||
bubbleObs?.disconnect()
|
||||
bodyObs = targetObs = bubbleObs = null
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const body = document.body
|
||||
if (body) {
|
||||
bodyObs = new ResizeObserver(scheduleMeasure)
|
||||
bodyObs.observe(body)
|
||||
}
|
||||
} catch {
|
||||
// ignore(个别环境不支持)
|
||||
}
|
||||
}
|
||||
|
||||
function observeTarget(el: Element | null) {
|
||||
try {
|
||||
targetObs?.disconnect()
|
||||
targetObs = null
|
||||
if (el && typeof ResizeObserver !== 'undefined') {
|
||||
targetObs = new ResizeObserver(scheduleMeasure)
|
||||
targetObs.observe(el)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function observeBubble(el: HTMLElement | null) {
|
||||
try {
|
||||
bubbleObs?.disconnect()
|
||||
bubbleObs = null
|
||||
if (el && typeof ResizeObserver !== 'undefined') {
|
||||
bubbleObs = new ResizeObserver(scheduleMeasure)
|
||||
bubbleObs.observe(el)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
step.value = 0
|
||||
void locate()
|
||||
startLayoutPolling()
|
||||
} else {
|
||||
locateSeq++
|
||||
stopLayoutPolling()
|
||||
holeRect.value = null
|
||||
curTarget.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(curTarget, (el) => observeTarget(el))
|
||||
watch(bubbleEl, (el) => observeBubble(el))
|
||||
|
||||
observeResize()
|
||||
window.addEventListener('scroll', scheduleMeasure, true)
|
||||
window.addEventListener('resize', scheduleMeasure)
|
||||
document.addEventListener('visibilitychange', scheduleMeasure)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
locateSeq++
|
||||
stopLayoutPolling()
|
||||
window.removeEventListener('scroll', scheduleMeasure, true)
|
||||
window.removeEventListener('resize', scheduleMeasure)
|
||||
document.removeEventListener('visibilitychange', scheduleMeasure)
|
||||
cancelAnimationFrame(rafId)
|
||||
try {
|
||||
bodyObs?.disconnect()
|
||||
targetObs?.disconnect()
|
||||
bubbleObs?.disconnect()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
/* ================= 样式计算 ================= */
|
||||
|
||||
const BUBBLE_W = 300
|
||||
|
||||
const holeStyle = computed(() => {
|
||||
const r = holeRect.value
|
||||
if (!r) return null
|
||||
return {
|
||||
top: `${r.top}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
borderRadius: `${r.r}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const bubbleStyle = computed(() => {
|
||||
const r = holeRect.value
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const bw = Math.min(BUBBLE_W, vw - 24)
|
||||
let style: Record<string, string> = { width: `${bw}px` }
|
||||
if (bubblePos.value === 'center' || !r) {
|
||||
style.top = '96px'
|
||||
style.left = `${Math.max(12, (vw - bw) / 2)}px`
|
||||
return style
|
||||
}
|
||||
// 水平对齐目标中心(防溢出)
|
||||
const left = Math.max(12, Math.min(vw - bw - 12, r.left + r.width / 2 - bw / 2))
|
||||
style.left = `${left}px`
|
||||
if (bubblePos.value === 'top') {
|
||||
style.bottom = `${Math.max(12, vh - r.top + 12)}px`
|
||||
} else {
|
||||
// r 为 {top,left,width,height},底部需用 top+height 合成(r.bottom 不存在会算出 NaNpx 被浏览器丢弃)
|
||||
style.top = `${Math.min(vh - 12, r.top + r.height + 12)}px`
|
||||
}
|
||||
return style
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="guide-fade">
|
||||
<div v-if="show" class="guide-root fixed inset-0 z-[80]" @touchmove.prevent @scroll.prevent>
|
||||
<!-- 暗色遮罩:高亮框阴影法挖洞(高亮框本身透明露出真实页面元素);mock 时全屏压暗 -->
|
||||
<div v-if="holeRect && !mockMode" :style="holeStyle" class="guide-hole" />
|
||||
<div v-else class="absolute inset-0 bg-black/60" />
|
||||
|
||||
<!-- 无真实任务时的示例任务卡(替换空态区展示,引导指向"执行"按钮) -->
|
||||
<div
|
||||
v-if="mockMode && mockCardStyle"
|
||||
:style="mockCardStyle"
|
||||
class="guide-mock-card absolute pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center justify-between p-3">
|
||||
<div class="flex-1 min-w-0 mr-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
|
||||
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:data-guide="step === 3 ? 'home-task-exec' : undefined"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
|
||||
:class="step === 3 ? 'guide-mock-pulse' : ''"
|
||||
title="执行签到"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tooltip 气泡(切步时保留同一实例,仅位置平滑过渡,不重放入场动画) -->
|
||||
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
|
||||
<!-- 顶部:标识 + 步骤 + 跳过 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
|
||||
<Sparkles class="w-3 h-3" />
|
||||
新手引导
|
||||
</span>
|
||||
<div class="flex items-center gap-1 flex-1">
|
||||
<span
|
||||
v-for="(s, i) in steps"
|
||||
:key="i"
|
||||
class="h-1 rounded-full transition-all duration-300"
|
||||
:class="i === step ? 'w-5 bg-blue-500' : i < step ? 'w-1.5 bg-blue-300' : 'w-1.5 bg-gray-200'"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-gray-500 hover:bg-gray-100 transition-colors flex-shrink-0"
|
||||
title="跳过引导"
|
||||
@click="skip"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 标题与说明 -->
|
||||
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
|
||||
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
|
||||
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center gap-2 mt-3.5">
|
||||
<button
|
||||
v-if="step > 0"
|
||||
class="flex items-center justify-center gap-0.5 h-9 px-3 rounded-lg border border-gray-200 text-gray-500 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
|
||||
@click="prev"
|
||||
>
|
||||
<ChevronLeft class="w-3.5 h-3.5" />
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-[13px] font-medium shadow-sm active:scale-[0.99] transition-all flex items-center justify-center gap-1"
|
||||
@click="next"
|
||||
>
|
||||
{{ isLast ? '完成' : '下一步' }}
|
||||
<ChevronRight v-if="!isLast" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide-root {
|
||||
background: transparent;
|
||||
}
|
||||
/* 高亮框:白色 ring + 全屏阴影挖洞(box-shadow 第一层 ring、第二层撑满全屏压暗) */
|
||||
.guide-hole {
|
||||
position: absolute;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.95),
|
||||
0 0 0 9999px rgba(0, 0, 0, 0.62);
|
||||
transition:
|
||||
top 0.25s ease,
|
||||
left 0.25s ease,
|
||||
width 0.25s ease,
|
||||
height 0.25s ease;
|
||||
}
|
||||
|
||||
.guide-mock-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.95),
|
||||
0 10px 28px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 第四步:示例卡执行按钮脉冲强调(引导点击开始打卡) */
|
||||
.guide-mock-pulse {
|
||||
animation: guide-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes guide-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.guide-bubble {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
|
||||
padding: 14px 16px 12px;
|
||||
/* 布局/文案变化实时重定位时平滑移动(动画只播放入场) */
|
||||
transition:
|
||||
top 0.25s ease,
|
||||
left 0.25s ease,
|
||||
bottom 0.25s ease;
|
||||
animation: guide-pop-in 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.guide-desc {
|
||||
max-height: 132px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.guide-fade-enter-active,
|
||||
.guide-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.guide-fade-enter-from,
|
||||
.guide-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes guide-pop-in {
|
||||
from {
|
||||
transform: translateY(8px) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ export function useMessage() {
|
||||
success: (msg: string) => addToast('success', msg),
|
||||
error: (msg: string) => addToast('error', msg),
|
||||
info: (msg: string) => addToast('info', msg),
|
||||
warning: (msg: string) => addToast('info', msg),
|
||||
removeToast,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
|
||||
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
|
||||
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
|
||||
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
|
||||
|
||||
const DARK_START_HOUR = 18
|
||||
const DARK_END_HOUR = 6
|
||||
const THEME_COLOR_LIGHT = '#f5f5f5'
|
||||
const THEME_COLOR_DARK = '#15151f'
|
||||
|
||||
export function isDarkTime(date: Date = new Date()): boolean {
|
||||
const h = date.getHours()
|
||||
return h >= DARK_START_HOUR || h < DARK_END_HOUR
|
||||
}
|
||||
|
||||
/** 应用当前时段对应主题,返回是否处于暗色 */
|
||||
export function applyTheme(): boolean {
|
||||
const dark = isDarkTime()
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', dark ? THEME_COLOR_DARK : THEME_COLOR_LIGHT)
|
||||
return dark
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
|
||||
export function initTheme(): void {
|
||||
applyTheme()
|
||||
if (timer) return
|
||||
timer = setInterval(applyTheme, 60_000)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Home, User, Bell, X, CheckCircle, ChevronDown, MessageSquare } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import GuideOverlay from '@/components/GuideOverlay.vue'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import * as ticketApi from '@/api/ticket'
|
||||
@@ -18,15 +19,17 @@ const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ name: 'home', label: '首页', icon: Home },
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
const activeTab = ref(route.name as string || 'home')
|
||||
|
||||
// 路由变化时同步底部 tab 高亮
|
||||
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
|
||||
watch(() => route.name, (name) => {
|
||||
if (name) activeTab.value = name as string
|
||||
if (name === 'home') maybeLoadAnnouncementPopups()
|
||||
})
|
||||
|
||||
// 公告相关
|
||||
@@ -52,6 +55,75 @@ const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
|
||||
const showDetail = ref(false)
|
||||
const detailItem = ref<AnnouncementItem | null>(null)
|
||||
|
||||
// 新手引导:是否展示由后端数据库记录判定(用户 guideVersion 未达当前版本即展示,
|
||||
// 完成/关闭后上报写库,跨设备不再重复打扰)。测试强播入口 ?guide=1 保留:
|
||||
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
|
||||
const GUIDE_FORCE_KEY = 'xz_guide_force'
|
||||
/** 与后端 handler.GuideCompleteVersion 一致 */
|
||||
const GUIDE_VERSION = 'v2'
|
||||
const showGuide = ref(false)
|
||||
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
|
||||
const guideForced = ref(false)
|
||||
// 邀请奖励规则(供引导第 4 步动态展示;接口失败保持 null,气泡用泛指文案)
|
||||
const inviterRewardPerInvite = ref<number | null>(null)
|
||||
const inviteeRewardPerInvite = ref<number | null>(null)
|
||||
|
||||
async function loadInviteRewardRules() {
|
||||
try {
|
||||
const res = await authApi.getMyInviteInfo()
|
||||
inviterRewardPerInvite.value = res.data.data?.inviterRewardPerInvite ?? null
|
||||
inviteeRewardPerInvite.value = res.data.data?.inviteeRewardPerInvite ?? null
|
||||
} catch {
|
||||
// 静默失败,引导文案退化为"可获得一定积分"
|
||||
}
|
||||
}
|
||||
|
||||
// 公告强制弹窗是否已加载(须在引导完成后才查询,避免弹窗与引导冲突)
|
||||
const announcementLoaded = ref(false)
|
||||
|
||||
function handleGuideAction(target: 'home' | 'profile') {
|
||||
switchTab(target)
|
||||
}
|
||||
|
||||
function closeGuide() {
|
||||
showGuide.value = false
|
||||
// 引导结束:补上公告未读数与强制弹窗队列
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
|
||||
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
|
||||
if (!guideForced.value) {
|
||||
authApi.completeGuide().catch(() => {
|
||||
// 上报失败不打扰用户:下次登录该账号会再次展示引导
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 个人中心「再次查看引导」入口:重播新手引导。GuideOverlay 对 show 重新置真会自动
|
||||
// 回到第一步;关闭时走 closeGuide 正常上报完成,顺带把未完成账号的引导版本补齐,
|
||||
// 语义与首次观看一致,故不沿用 guideForced 的"关闭不写库"逻辑。
|
||||
function restartGuide() {
|
||||
showGuide.value = true
|
||||
loadInviteRewardRules()
|
||||
}
|
||||
provide('restartGuide', restartGuide)
|
||||
|
||||
/** 数据库判定:guideVersion 未达当前引导版本的用户需展示引导 */
|
||||
function needGuide(user: UserInfo): boolean {
|
||||
return (user.guideVersion || '') !== GUIDE_VERSION
|
||||
}
|
||||
|
||||
async function maybeLoadAnnouncementPopups() {
|
||||
if (announcementLoaded.value || showGuide.value || route.name !== 'home') return
|
||||
announcementLoaded.value = true
|
||||
try {
|
||||
const res = await announceApi.getMyAnnouncementInfo()
|
||||
forcePopupQueue.value = res.data.data.popupQueue || []
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(name: string) {
|
||||
activeTab.value = name
|
||||
router.push({ name })
|
||||
@@ -140,31 +212,36 @@ async function handleForcePopupDismiss() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 强播标记一次性消费:仅本次整页加载(URL 带 ?guide=1)内优先展示且关闭不写库;
|
||||
// 读取后立即清除,避免会话残留把后续普通访问(刷新/重开)误判为强播——
|
||||
// 否则会出现"看完引导后刷新又弹、且永不上报完成"的假象
|
||||
guideForced.value = sessionStorage.getItem(GUIDE_FORCE_KEY) === '1'
|
||||
sessionStorage.removeItem(GUIDE_FORCE_KEY)
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
const data = res.data.data
|
||||
userInfo.value = data
|
||||
|
||||
if (!data.mobileCode) {
|
||||
router.replace('/bind-device')
|
||||
return
|
||||
// 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导)
|
||||
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
|
||||
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
|
||||
if (guideForced.value || needGuide(data)) {
|
||||
showGuide.value = true
|
||||
// 引导气泡中展示邀请奖励数值(第 4 步)
|
||||
loadInviteRewardRules()
|
||||
}
|
||||
} catch {
|
||||
message.error('获取用户信息失败')
|
||||
}
|
||||
|
||||
// 加载未读公告数 + 待弹窗队列(仅首页触发弹窗)
|
||||
await loadUnreadCount()
|
||||
if (route.name === 'home') {
|
||||
try {
|
||||
const res = await announceApi.getMyAnnouncementInfo()
|
||||
forcePopupQueue.value = res.data.data.popupQueue || []
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 引导展示中不查询公告信息(未读数/强制弹窗),避免公告弹窗与引导冲突;
|
||||
// 引导关闭后由 closeGuide 统一补齐
|
||||
if (!showGuide.value) {
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
}
|
||||
|
||||
// 定时轮询未读数
|
||||
// 定时轮询未读数(仅更新铃铛角标,不产生弹窗)
|
||||
pollTimer = setInterval(loadUnreadCount, 30000)
|
||||
})
|
||||
|
||||
@@ -201,8 +278,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间内容区(可滚动) -->
|
||||
<div class="flex-1 overflow-y-auto bg-[#f5f5f5]">
|
||||
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
|
||||
<div class="flex-1 overflow-y-auto bg-background">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
@@ -407,6 +484,25 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
|
||||
<button
|
||||
v-if="!showGuide"
|
||||
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
|
||||
@click="restartGuide"
|
||||
>
|
||||
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
忘记操作?再次查看引导
|
||||
</button>
|
||||
|
||||
<!-- ======== 首次登录新手引导(最高层:遮罩高亮真实页面元素 + 气泡推进) ======== -->
|
||||
<GuideOverlay
|
||||
:show="showGuide"
|
||||
:inviter-reward-per-invite="inviterRewardPerInvite"
|
||||
:invitee-reward-per-invite="inviteeRewardPerInvite"
|
||||
@goto="handleGuideAction"
|
||||
@close="closeGuide"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -428,4 +524,13 @@ onUnmounted(() => {
|
||||
.slide-up-leave-to {
|
||||
transition: translateY(100%);
|
||||
}
|
||||
|
||||
/* 引导入口气泡:上下浮动跳动,吸引注意但不刺眼 */
|
||||
@keyframes guide-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
.guide-entry {
|
||||
animation: guide-bob 1.5s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,21 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles/globals.css'
|
||||
import { initTheme } from './composables/useTheme'
|
||||
|
||||
// 亮暗主题自动切换(18:00–次日 06:00 暗色);首屏已由 index.html 内联脚本预设,
|
||||
// 这里立即校正一次并启动分钟级轮询,跨过时间阈值时无刷新自动切换
|
||||
initTheme()
|
||||
|
||||
// 测试入口:URL 带 ?guide=1 强制重播新手引导(不受本地完成标记影响)。
|
||||
// 立即把标记落入 sessionStorage 并移除 URL 参数(登录跳转会丢失 query,故不能等到 MobileLayout)
|
||||
const guideParams = new URLSearchParams(window.location.search)
|
||||
if (guideParams.get('guide') === '1') {
|
||||
sessionStorage.setItem('xz_guide_force', '1')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('guide')
|
||||
window.history.replaceState(null, '', url)
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
|
||||
@@ -2,8 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LoginView from '@/views/login/LoginView.vue'
|
||||
import HomeView from '@/views/home/HomeView.vue'
|
||||
import ProfileView from '@/views/profile/ProfileView.vue'
|
||||
import LeaveView from '@/views/leave/LeaveView.vue'
|
||||
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
|
||||
import MobileLayout from '@/layouts/MobileLayout.vue'
|
||||
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
|
||||
|
||||
import TicketList from '@/views/ticket/TicketList.vue'
|
||||
import TicketDetail from '@/views/ticket/TicketDetail.vue'
|
||||
@@ -65,31 +67,37 @@ const router = createRouter({
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminUsers,
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'admin-settings',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminSettings,
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'admin-announcements',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminAnnouncements,
|
||||
},
|
||||
{
|
||||
path: 'tickets',
|
||||
name: 'admin-tickets',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminTicketList,
|
||||
},
|
||||
{
|
||||
path: 'tickets/:id',
|
||||
name: 'admin-ticket-detail',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminTicketDetail,
|
||||
},
|
||||
{
|
||||
path: 'pending-schools',
|
||||
name: 'admin-pending-schools',
|
||||
meta: { adminOnly: true },
|
||||
component: AdminPendingSchools,
|
||||
},
|
||||
],
|
||||
@@ -99,6 +107,12 @@ const router = createRouter({
|
||||
path: '/',
|
||||
redirect: '/login',
|
||||
},
|
||||
// 微信/QQ 内置浏览器引导页:提示复制链接用系统浏览器打开
|
||||
{
|
||||
path: '/open-in-browser',
|
||||
name: 'open-in-browser',
|
||||
component: BrowserGuideView,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
@@ -132,6 +146,11 @@ const router = createRouter({
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: 'leave',
|
||||
name: 'leave',
|
||||
component: LeaveView,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
@@ -142,11 +161,31 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// 微信/QQ 系内置浏览器检测:
|
||||
// - MicroMessenger: 微信内置浏览器
|
||||
// - QQ/: 手机 QQ 内置浏览器
|
||||
// 注意:不拦纯 QQ 浏览器 App(MQQBrowser),因为安卓微信右上角
|
||||
// "在浏览器打开"唤起的就是它,拦截会造成引导页死循环
|
||||
function isBlockedBrowser(): boolean {
|
||||
const ua = navigator.userAgent
|
||||
return /MicroMessenger|QQ\//i.test(ua)
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, _from) => {
|
||||
// 调试:记录当前路径
|
||||
try {
|
||||
await fetch(`/api/admin/setup/debug?path=${encodeURIComponent(to.path)}`)
|
||||
} catch {}
|
||||
// ======== 微信/QQ 浏览器拦截(优先于一切认证逻辑)========
|
||||
// 引导页自身放行;但若当前已是正常浏览器(如从微信右上角
|
||||
// "在浏览器打开"跳转过来),自动回到 redirect 指向的原页面
|
||||
if (to.name === 'open-in-browser') {
|
||||
if (!isBlockedBrowser()) {
|
||||
const r = to.query.redirect
|
||||
const target = typeof r === 'string' && r.startsWith('/') && r !== to.fullPath ? r : '/'
|
||||
return target
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (isBlockedBrowser()) {
|
||||
return { name: 'open-in-browser', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
const adminToken = localStorage.getItem('admin_token')
|
||||
@@ -208,6 +247,12 @@ router.beforeEach(async (to, _from) => {
|
||||
localStorage.removeItem('admin_info')
|
||||
return '/admin/login'
|
||||
}
|
||||
// 代理(agent)只能访问仪表盘 / 代理管理 / CDK管理,其余页面重定向回仪表盘
|
||||
const meData = await meRes.json()
|
||||
const role = meData?.data?.role
|
||||
if (role && role !== 'admin' && to.meta.adminOnly) {
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
} catch {
|
||||
// 网络错误,放行让组件自己处理
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
|
||||
*
|
||||
* 执行顺序刻意为先同步 execCommand:
|
||||
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
// 必须可见且在屏幕内(不可 display:none / 移出视口),否则 iOS Safari 复制失败;
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 忽略,走失败返回
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,27 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { QrCode, Plus, Search, X, LoaderCircle, Copy, CheckCheck } from 'lucide-vue-next'
|
||||
import {
|
||||
QrCode,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
LoaderCircle,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
Ban,
|
||||
RotateCcw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const cdks = ref<adminApi.CDKCode[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const keywordInput = ref('')
|
||||
const statusFilter = ref('')
|
||||
const copiedIndex = ref<number | null>(null)
|
||||
const copiedResultIdx = ref<number | null>(null)
|
||||
const copiedAll = ref(false)
|
||||
|
||||
// 分页
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
// 汇总(后端按可见范围统计)
|
||||
const summary = ref<adminApi.CDKSummary>({ total: 0, unused: 0, used: 0, disabled: 0 })
|
||||
|
||||
const adminInfo = ref<adminApi.AgentInfo | null>(null)
|
||||
const isAdmin = computed(() => adminInfo.value?.role === 'admin')
|
||||
|
||||
// 生成CDK弹窗
|
||||
// 生成CDK弹窗(form 配置 / result 展示结果)
|
||||
const showGenerateModal = ref(false)
|
||||
const modalView = ref<'form' | 'result'>('form')
|
||||
const generateCount = ref(1)
|
||||
const generateMaxRuns = ref(1)
|
||||
const generateType = ref<'count' | 'vip'>('count')
|
||||
const generateVipDays = ref(30)
|
||||
const generateLoading = ref(false)
|
||||
const resultCdks = ref<adminApi.CDKCode[]>([])
|
||||
const resultCost = ref(0)
|
||||
|
||||
// 禁用/恢复确认弹窗
|
||||
const showStatusModal = ref(false)
|
||||
const statusAction = ref<'disable' | 'enable'>('disable')
|
||||
const statusTarget = ref<adminApi.CDKCode | null>(null)
|
||||
const statusLoading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -34,44 +73,77 @@ onMounted(async () => {
|
||||
async function loadCDKs() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
const res = await adminApi.getAllCDKs()
|
||||
cdks.value = res.data?.data || []
|
||||
} else {
|
||||
const res = await adminApi.getMyCDKs()
|
||||
cdks.value = res.data?.data || []
|
||||
const params: adminApi.CDKListParams = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: searchKeyword.value,
|
||||
status: statusFilter.value,
|
||||
}
|
||||
const res = isAdmin.value ? await adminApi.getAllCDKs(params) : await adminApi.getMyCDKs(params)
|
||||
const data = res.data?.data
|
||||
cdks.value = data?.items || []
|
||||
total.value = data?.total || 0
|
||||
if (data?.summary) summary.value = data.summary
|
||||
} catch {
|
||||
message.error('加载CDK列表失败')
|
||||
cdks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCDKs = computed(() => {
|
||||
if (!searchQuery.value.trim()) return cdks.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return cdks.value.filter((c) => c.code.toLowerCase().includes(q))
|
||||
})
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
searchKeyword.value = keywordInput.value.trim()
|
||||
loadCDKs()
|
||||
}
|
||||
|
||||
function handleStatusChange() {
|
||||
page.value = 1
|
||||
loadCDKs()
|
||||
}
|
||||
|
||||
function handlePage(p: number) {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
page.value = p
|
||||
loadCDKs()
|
||||
}
|
||||
|
||||
function handlePageSize(size: number) {
|
||||
if (size === pageSize.value) return
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
loadCDKs()
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (generateCount.value <= 0 || generateCount.value > 100) {
|
||||
message.info('数量范围为 1-100')
|
||||
return
|
||||
}
|
||||
if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
|
||||
if (generateType.value === 'vip') {
|
||||
if (generateVipDays.value !== 7 && generateVipDays.value !== 30) {
|
||||
message.info('VIP CDK 仅支持周卡(7 天)或月卡(30 天)')
|
||||
return
|
||||
}
|
||||
} else if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
|
||||
message.info('运行次数范围为 1-999')
|
||||
return
|
||||
}
|
||||
generateLoading.value = true
|
||||
try {
|
||||
const res = await adminApi.generateCDK(generateCount.value, generateMaxRuns.value)
|
||||
const res = await adminApi.generateCDK(
|
||||
generateCount.value,
|
||||
generateMaxRuns.value,
|
||||
generateType.value,
|
||||
generateVipDays.value
|
||||
)
|
||||
const data = res.data?.data
|
||||
message.success(`成功生成 ${data?.count || 0} 个CDK(每张 ${data?.maxRuns || 1} 次)`)
|
||||
showGenerateModal.value = false
|
||||
generateCount.value = 1
|
||||
generateMaxRuns.value = 1
|
||||
await loadCDKs()
|
||||
resultCdks.value = data?.cdkCodes || []
|
||||
resultCost.value = data?.costTotal || 0
|
||||
copiedAll.value = false
|
||||
// 切到结果视图,展示全部CDK供复制
|
||||
modalView.value = 'result'
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '生成失败')
|
||||
} finally {
|
||||
@@ -79,30 +151,99 @@ async function handleGenerate() {
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
modalView.value = 'form'
|
||||
generateCount.value = 1
|
||||
generateMaxRuns.value = 1
|
||||
generateType.value = 'count'
|
||||
generateVipDays.value = 30
|
||||
showGenerateModal.value = true
|
||||
}
|
||||
|
||||
function closeGenerateModal() {
|
||||
showGenerateModal.value = false
|
||||
// 返回首页重新加载最新数据
|
||||
page.value = 1
|
||||
searchKeyword.value = ''
|
||||
keywordInput.value = ''
|
||||
statusFilter.value = ''
|
||||
loadCDKs()
|
||||
}
|
||||
|
||||
async function copyCode(code: string, index: number) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (await copyToClipboard(code)) {
|
||||
copiedIndex.value = index
|
||||
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
/** 统计 */
|
||||
const stats = computed(() => ({
|
||||
total: cdks.value.length,
|
||||
used: cdks.value.filter((c) => c.status === 'used').length,
|
||||
unused: cdks.value.filter((c) => c.status === 'unused').length,
|
||||
}))
|
||||
async function copyResultCode(code: string, index: number) {
|
||||
if (await copyToClipboard(code)) {
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAllCodes() {
|
||||
if (resultCdks.value.length === 0) return
|
||||
const all = resultCdks.value.map((c) => c.code).join('\n')
|
||||
if (await copyToClipboard(all)) {
|
||||
copiedAll.value = true
|
||||
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
|
||||
setTimeout(() => { copiedAll.value = false }, 2000)
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
function askDisable(cdk: adminApi.CDKCode) {
|
||||
statusAction.value = 'disable'
|
||||
statusTarget.value = cdk
|
||||
showStatusModal.value = true
|
||||
}
|
||||
|
||||
function askEnable(cdk: adminApi.CDKCode) {
|
||||
statusAction.value = 'enable'
|
||||
statusTarget.value = cdk
|
||||
showStatusModal.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
const target = statusTarget.value
|
||||
if (!target) return
|
||||
statusLoading.value = true
|
||||
try {
|
||||
await adminApi.updateCDKStatus(target.id, statusAction.value)
|
||||
message.success(statusAction.value === 'disable' ? '已禁用该CDK' : '已恢复该CDK')
|
||||
showStatusModal.value = false
|
||||
statusTarget.value = null
|
||||
await loadCDKs()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const statusText = computed(() => (statusAction.value === 'disable' ? '禁用CDK' : '恢复CDK'))
|
||||
|
||||
function statusLabel(s: string) {
|
||||
if (s === 'used') return '已使用'
|
||||
if (s === 'disabled') return '已禁用'
|
||||
return '未使用'
|
||||
}
|
||||
|
||||
function statusClass(s: string) {
|
||||
if (s === 'used') return 'bg-gray-100 text-gray-600'
|
||||
if (s === 'disabled') return 'bg-red-50 text-red-600'
|
||||
return 'bg-green-100 text-green-700'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,32 +251,57 @@ const stats = computed(() => ({
|
||||
<!-- 顶部 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
|
||||
<Button variant="default" size="sm" @click="showGenerateModal = true">
|
||||
<Button variant="default" size="sm" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
生成CDK
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 统计 -->
|
||||
<div class="flex gap-4 mb-4">
|
||||
<div class="flex gap-4 mb-4 flex-wrap">
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">总计</span>
|
||||
<span class="text-lg font-bold text-gray-900">{{ stats.total }}</span>
|
||||
<span class="text-lg font-bold text-gray-900">{{ summary.total }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-green-600">未使用</span>
|
||||
<span class="text-lg font-bold text-green-700">{{ stats.unused }}</span>
|
||||
<span class="text-lg font-bold text-green-700">{{ summary.unused }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">已使用</span>
|
||||
<span class="text-lg font-bold text-gray-700">{{ stats.used }}</span>
|
||||
<span class="text-lg font-bold text-gray-700">{{ summary.used }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-red-500">已禁用</span>
|
||||
<span class="text-lg font-bold text-red-600">{{ summary.disabled }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="relative mb-4">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input v-model="searchQuery" placeholder="搜索CDK码..." class="pl-9 h-10 text-sm max-w-xs" />
|
||||
<!-- 筛选:搜索 + 状态 -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keywordInput"
|
||||
placeholder="搜索CDK码..."
|
||||
class="pl-9 h-10 text-sm"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-10" @click="handleSearch">
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="h-10 px-3 rounded-lg border border-gray-200 bg-white text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleStatusChange"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="unused">未使用</option>
|
||||
<option value="used">已使用</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
@@ -144,7 +310,7 @@ const stats = computed(() => ({
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredCDKs.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||
<div v-else-if="cdks.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
|
||||
<p>暂无CDK数据</p>
|
||||
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
|
||||
@@ -154,76 +320,291 @@ const stats = computed(() => ({
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<th class="px-4 py-3 font-medium">CDK码</th>
|
||||
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
|
||||
<th class="px-4 py-3 font-medium">售价</th>
|
||||
<th class="px-4 py-3 font-medium">次数</th>
|
||||
<th class="px-4 py-3 font-medium">类型</th>
|
||||
<th class="px-4 py-3 font-medium">额度</th>
|
||||
<th class="px-4 py-3 font-medium">状态</th>
|
||||
<th class="px-4 py-3 font-medium">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(cdk, idx) in filteredCDKs" :key="cdk.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||
<tr
|
||||
v-for="(cdk, idx) in cdks"
|
||||
:key="cdk.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
:class="cdk.status === 'disabled' ? 'opacity-60' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
|
||||
</td>
|
||||
<td v-if="isAdmin" class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
{{ (cdk as any).agent?.username || cdk.agentId }}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ cdk.price.toFixed(2) }} 元</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
v-if="cdk.type === 'vip'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
|
||||
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
|
||||
>次数卡</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
{{ cdk.maxRuns ?? 1 }}
|
||||
{{ cdk.type === 'vip' ? `${cdk.vipDays} 天` : `${cdk.maxRuns ?? 1} 次` }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : 'bg-green-100 text-green-700'"
|
||||
:class="statusClass(cdk.status)"
|
||||
>
|
||||
{{ cdk.status === 'used' ? '已使用' : '未使用' }}
|
||||
{{ statusLabel(cdk.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
|
||||
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
|
||||
@click="copyCode(cdk.code, idx)"
|
||||
>
|
||||
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
|
||||
<CheckCheck v-else class="w-3 h-3" />
|
||||
{{ copiedIndex === idx ? '已复制' : '复制' }}
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
|
||||
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
|
||||
@click="copyCode(cdk.code, idx)"
|
||||
>
|
||||
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
|
||||
<CheckCheck v-else class="w-3 h-3" />
|
||||
{{ copiedIndex === idx ? '已复制' : '复制' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="cdk.status === 'unused'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
@click="askDisable(cdk)"
|
||||
>
|
||||
<Ban class="w-3 h-3" />
|
||||
禁用
|
||||
</button>
|
||||
<button
|
||||
v-else-if="cdk.status === 'disabled'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-600 hover:bg-green-50 transition-colors"
|
||||
@click="askEnable(cdk)"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3" />
|
||||
恢复
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
|
||||
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="page <= 1"
|
||||
@click="handlePage(page - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="page >= totalPages"
|
||||
@click="handlePage(page + 1)"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成CDK弹窗 -->
|
||||
<!-- 生成CDK弹窗(form / result 两视图) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showGenerateModal = false">
|
||||
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="modalView === 'form' ? (showGenerateModal = false) : null">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-xl w-full mx-4 p-6 flex flex-col"
|
||||
:class="modalView === 'result' ? 'max-w-lg max-h-[80vh]' : 'max-w-sm'"
|
||||
>
|
||||
<!-- 配置视图 -->
|
||||
<template v-if="modalView === 'form'">
|
||||
<div class="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
|
||||
@click="generateType = 'count'"
|
||||
>次数 CDK</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateType = 'vip'"
|
||||
>⭐ VIP CDK</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
|
||||
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
|
||||
</div>
|
||||
<template v-if="generateType === 'count'">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
|
||||
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 7"
|
||||
>周卡 · 7天</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 30"
|
||||
>月卡 · 30天</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
|
||||
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
|
||||
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||
{{ generateLoading ? '生成中...' : '生成' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 结果视图:展示全部CDK -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<CheckCircle2 class="w-5 h-5 text-green-500" />
|
||||
生成成功
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
共 {{ resultCdks.length }} 个CDK ·
|
||||
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
|
||||
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
|
||||
扣费
|
||||
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CDK 列表(可滚动) -->
|
||||
<div class="flex-1 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50/60 min-h-[120px] max-h-[40vh]">
|
||||
<div
|
||||
v-for="cdk in resultCdks"
|
||||
:key="cdk.id"
|
||||
class="flex items-center justify-between px-3 py-2 border-b border-gray-100 last:border-0 bg-white"
|
||||
>
|
||||
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
|
||||
<button
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
|
||||
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
|
||||
>
|
||||
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
|
||||
<CheckCheck v-else class="w-3 h-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4 flex-shrink-0">
|
||||
<Button variant="outline" class="h-10 px-4" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
继续生成
|
||||
</Button>
|
||||
<Button class="flex-1 h-10" @click="copyAllCodes">
|
||||
<CheckCheck v-if="copiedAll" class="w-4 h-4 mr-1" />
|
||||
<Copy v-else class="w-4 h-4 mr-1" />
|
||||
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-gray-300 mt-2">
|
||||
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 禁用/恢复确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showStatusModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showStatusModal = false">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="showGenerateModal = false">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
|
||||
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
:class="statusAction === 'disable' ? 'bg-red-100' : 'bg-green-100'"
|
||||
>
|
||||
<Ban v-if="statusAction === 'disable'" class="w-4 h-4 text-red-500" />
|
||||
<RotateCcw v-else class="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
|
||||
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
|
||||
<h2 class="text-base font-semibold text-gray-900">{{ statusText }}</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ statusTarget?.code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
|
||||
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
|
||||
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||
{{ generateLoading ? '生成中...' : '生成' }}
|
||||
<p class="text-sm text-gray-500 leading-relaxed text-justify">
|
||||
<template v-if="statusAction === 'disable'">
|
||||
禁用后该CDK将<strong class="text-red-500">无法被用户兑换</strong>,如需使用可随时恢复。
|
||||
</template>
|
||||
<template v-else>
|
||||
恢复后该CDK将重新变为<strong class="text-green-600">未使用</strong>状态,可被用户兑换。
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10" @click="showStatusModal = false">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10"
|
||||
:class="statusAction === 'disable' ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
||||
:disabled="statusLoading"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
<LoaderCircle v-if="statusLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||
{{ statusAction === 'disable' ? '确认禁用' : '确认恢复' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,14 +31,15 @@ onMounted(async () => {
|
||||
|
||||
me.value = meRes.data?.data || null
|
||||
subAgents.value = agentRes.data?.data || []
|
||||
cdks.value = cdkRes.data?.data || []
|
||||
cdks.value = cdkRes.data?.data?.items || []
|
||||
|
||||
const agents = agentRes.data?.data || []
|
||||
const summary = cdkRes.data?.data?.summary
|
||||
stats.value = {
|
||||
totalAgents: agents.length,
|
||||
activeAgents: agents.filter((a) => a.status === 'active').length,
|
||||
totalCDKs: cdks.value.length,
|
||||
usedCDKs: cdks.value.filter((c) => c.status === 'used').length,
|
||||
totalCDKs: summary?.total ?? cdks.value.length,
|
||||
usedCDKs: summary?.used ?? cdks.value.filter((c) => c.status === 'used').length,
|
||||
balance: me.value?.balance || 0,
|
||||
rate: me.value?.rate ?? 1,
|
||||
}
|
||||
@@ -133,9 +134,9 @@ onMounted(async () => {
|
||||
<td class="py-2.5">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : 'bg-green-100 text-green-700'"
|
||||
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : cdk.status === 'disabled' ? 'bg-red-50 text-red-600' : 'bg-green-100 text-green-700'"
|
||||
>
|
||||
{{ cdk.status === 'used' ? '已使用' : '未使用' }}
|
||||
{{ cdk.status === 'used' ? '已使用' : cdk.status === 'disabled' ? '已禁用' : '未使用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||
|
||||
@@ -33,17 +33,22 @@ const adminInfo = ref<AgentInfo | null>(null)
|
||||
const showUserMenu = ref(false)
|
||||
const showSidebar = ref(false)
|
||||
|
||||
const navItems = [
|
||||
const navItems: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }[] = [
|
||||
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
|
||||
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
|
||||
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users' },
|
||||
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users', adminOnly: true },
|
||||
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
|
||||
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
|
||||
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
|
||||
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools' },
|
||||
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings' },
|
||||
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements', adminOnly: true },
|
||||
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets', adminOnly: true },
|
||||
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools', adminOnly: true },
|
||||
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings', adminOnly: true },
|
||||
]
|
||||
|
||||
// 代理(agent)只能访问:仪表盘、代理管理、CDK管理
|
||||
const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
|
||||
)
|
||||
|
||||
const activeNav = computed(() => route.name as string)
|
||||
|
||||
// 公告相关(代理端)
|
||||
@@ -130,7 +135,7 @@ function dismissForcePopup() {
|
||||
forcePopupQueue.value = forcePopupQueue.value.slice(1)
|
||||
}
|
||||
|
||||
function navigateTo(item: typeof navItems[0]) {
|
||||
function navigateTo(item: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }) {
|
||||
router.push({ name: item.name })
|
||||
showSidebar.value = false
|
||||
}
|
||||
@@ -217,7 +222,7 @@ function handleLogout() {
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.name"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||
:class="activeNav === item.name
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import api from '@/api/index'
|
||||
|
||||
interface PendingSchoolItem {
|
||||
@@ -15,6 +16,7 @@ interface PendingSchoolItem {
|
||||
email: string
|
||||
ip: string
|
||||
status: string
|
||||
rejectReason: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -34,6 +36,10 @@ const searchKeyword = ref('')
|
||||
const showPasswordMap = ref<Record<number, boolean>>({})
|
||||
// 已适配学校:schoolId -> 学校名(用于在待处理列表中标识已适配过的学校)
|
||||
const adaptedSchools = ref<Map<string, string>>(new Map())
|
||||
// 拒绝弹窗
|
||||
const rejectTarget = ref<PendingSchoolItem | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const rejectSubmitting = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
@@ -102,10 +108,45 @@ async function handleMarkDone(record: PendingSchoolItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
function openRejectModal(record: PendingSchoolItem) {
|
||||
rejectTarget.value = record
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
function closeRejectModal() {
|
||||
if (rejectSubmitting.value) return
|
||||
rejectTarget.value = null
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const target = rejectTarget.value
|
||||
if (!target) return
|
||||
const reason = rejectReason.value.trim()
|
||||
if (!reason) {
|
||||
message.error('拒绝时必须说明理由')
|
||||
return
|
||||
}
|
||||
rejectSubmitting.value = true
|
||||
try {
|
||||
await api.post(`/admin/pending-schools/${target.id}/reject`, { reason })
|
||||
message.success(`已拒绝该条记录,拒绝理由将邮件通知联系邮箱`)
|
||||
closeRejectModal()
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
rejectSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
message.success('已复制')
|
||||
})
|
||||
} else {
|
||||
message.error('复制失败,请长按手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -135,6 +176,11 @@ function copyText(text: string) {
|
||||
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="statusFilter = 'completed'; loadData()"
|
||||
>已完成</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="statusFilter = 'rejected'; loadData()"
|
||||
>已拒绝</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@@ -212,19 +258,32 @@ function copyText(text: string) {
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="statusFilter !== 'completed'"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="handleMarkDone(group.records[0])"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 mr-1" />
|
||||
标记完成
|
||||
</Button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
|
||||
size="sm"
|
||||
class="h-7 text-xs whitespace-nowrap"
|
||||
@click="handleMarkDone(group.records[0])"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
标记完成
|
||||
</Button>
|
||||
<Button
|
||||
v-if="statusFilter === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
|
||||
@click="openRejectModal(group.records[0])"
|
||||
>
|
||||
<XCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号列表 -->
|
||||
<table class="w-full text-sm">
|
||||
<!-- 账号列表(小屏横向滚动) -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[760px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
|
||||
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
|
||||
@@ -284,29 +343,82 @@ function copyText(text: string) {
|
||||
<td class="px-5 py-3">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
|
||||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
|
||||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
|
||||
>
|
||||
{{ r.status === 'pending' ? '待处理' : '已完成' }}
|
||||
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
|
||||
</span>
|
||||
<p
|
||||
v-if="r.status === 'rejected' && r.rejectReason"
|
||||
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
|
||||
:title="r.rejectReason"
|
||||
>理由:{{ r.rejectReason }}</p>
|
||||
</td>
|
||||
<!-- 提交时间 -->
|
||||
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
|
||||
<!-- 操作 -->
|
||||
<td class="px-5 py-3">
|
||||
<button
|
||||
v-if="r.status === 'pending'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
@click="handleMarkDone(r)"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3" />
|
||||
完成
|
||||
</button>
|
||||
<td class="px-5 py-3 whitespace-nowrap">
|
||||
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
@click="handleMarkDone(r)"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 shrink-0" />
|
||||
完成
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-red-500 hover:bg-red-50 transition-colors"
|
||||
@click="openRejectModal(r)"
|
||||
>
|
||||
<XCircle class="w-3 h-3 shrink-0" />
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拒绝理由弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rejectTarget"
|
||||
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
|
||||
@click.self="closeRejectModal"
|
||||
>
|
||||
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<XCircle class="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
|
||||
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
|
||||
<textarea
|
||||
v-model="rejectReason"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
|
||||
<XCircle class="w-3.5 h-3.5 mr-1" />
|
||||
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,8 @@ const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const baseDeduction = ref('')
|
||||
const vipWeekPrice = ref('')
|
||||
const vipMonthPrice = ref('')
|
||||
const inviterReward = ref('')
|
||||
const inviteeReward = ref('')
|
||||
const registerReward = ref('')
|
||||
@@ -39,6 +41,8 @@ async function loadSettings() {
|
||||
const res = await adminApi.getSettings()
|
||||
const settings = res.data?.data || {}
|
||||
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
|
||||
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
|
||||
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
|
||||
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
|
||||
registerReward.value = settings['register_reward'] || '1.00'
|
||||
@@ -123,10 +127,22 @@ async function handleSave() {
|
||||
message.info('直接注册奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const vipWeek = parseFloat(vipWeekPrice.value)
|
||||
if (isNaN(vipWeek) || vipWeek <= 0) {
|
||||
message.info('VIP 周卡售价请输入有效的正数')
|
||||
return
|
||||
}
|
||||
const vipMonth = parseFloat(vipMonthPrice.value)
|
||||
if (isNaN(vipMonth) || vipMonth <= 0) {
|
||||
message.info('VIP 月卡售价请输入有效的正数')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await adminApi.updateSetting('base_deduction_per_run', deduction.toFixed(2))
|
||||
await adminApi.updateSetting('vip_price_week', vipWeek.toFixed(2))
|
||||
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
|
||||
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
|
||||
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
|
||||
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
|
||||
@@ -168,6 +184,34 @@ async function handleSave() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
|
||||
<Input
|
||||
v-model="vipWeekPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
|
||||
<Input
|
||||
v-model="vipMonthPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="saving" @click="handleSave">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LogIn,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -47,12 +48,17 @@ interface UserItem {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
taskCount: number
|
||||
executedCount: number
|
||||
vipStatus: string
|
||||
vipExpireAt: string
|
||||
todayStatus: string
|
||||
status: string
|
||||
createdAt: string
|
||||
lastLoginAt: string
|
||||
lastLoginIp: string
|
||||
}
|
||||
|
||||
interface TodayStats {
|
||||
@@ -73,6 +79,8 @@ interface UserDetail {
|
||||
signCount: number
|
||||
inviteReward: number
|
||||
invitedCount: number
|
||||
inviterId: number | null
|
||||
inviterName: string
|
||||
status: string
|
||||
disabledReason: string
|
||||
taskCount: number
|
||||
@@ -80,6 +88,7 @@ interface UserDetail {
|
||||
mobileType: string
|
||||
createdAt: string
|
||||
lastLoginAt: string
|
||||
lastLoginIp: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -103,7 +112,128 @@ const disableTarget = ref<UserItem | null>(null)
|
||||
const disableReason = ref('')
|
||||
const disabling = ref(false)
|
||||
|
||||
// 当天执行日志弹窗
|
||||
interface AdminTaskLogItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
taskType: string
|
||||
title: string
|
||||
realTitle: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const showLogsModal = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const logItems = ref<AdminTaskLogItem[]>([])
|
||||
const logsTotal = ref(0)
|
||||
const logsPage = ref(1)
|
||||
const logsPageSize = ref(20)
|
||||
const logsDate = ref(todayStr())
|
||||
const logsStatus = ref<'all' | 'success' | 'fail'>('all')
|
||||
const logsKeyword = ref('')
|
||||
const logsSearchKeyword = ref('')
|
||||
|
||||
const logsStatusOptions = [
|
||||
{ key: 'all' as const, label: '全部' },
|
||||
{ key: 'success' as const, label: '成功' },
|
||||
{ key: 'fail' as const, label: '失败' },
|
||||
]
|
||||
|
||||
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
function openTodayLogs() {
|
||||
logsDate.value = todayStr()
|
||||
logsStatus.value = 'all'
|
||||
logsKeyword.value = ''
|
||||
logsSearchKeyword.value = ''
|
||||
logsPage.value = 1
|
||||
showLogsModal.value = true
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
async function loadTodayLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await adminApi.getAdminTaskLogs({
|
||||
date: logsDate.value,
|
||||
status: logsStatus.value,
|
||||
keyword: logsSearchKeyword.value,
|
||||
page: logsPage.value,
|
||||
pageSize: logsPageSize.value,
|
||||
})
|
||||
const data = res.data?.data
|
||||
logItems.value = data?.items || []
|
||||
logsTotal.value = data?.total || 0
|
||||
} catch {
|
||||
message.error('加载执行日志失败')
|
||||
logItems.value = []
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogsStatusChange(val: 'all' | 'success' | 'fail') {
|
||||
if (logsStatus.value === val) return
|
||||
logsStatus.value = val
|
||||
logsPage.value = 1
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsDateChange(e: Event) {
|
||||
logsDate.value = (e.target as HTMLInputElement).value || todayStr()
|
||||
logsPage.value = 1
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsSearch() {
|
||||
logsPage.value = 1
|
||||
logsSearchKeyword.value = logsKeyword.value.trim()
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function handleLogsPage(p: number) {
|
||||
if (p < 1 || p > logsTotalPages.value) return
|
||||
logsPage.value = p
|
||||
loadTodayLogs()
|
||||
}
|
||||
|
||||
function formatLogTime(iso: string) {
|
||||
if (!iso) return '--'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// 当天已登录判定:lastLoginAt 为服务器时间 "YYYY-MM-DD HH:mm:ss",取日期部分与本地今天比对
|
||||
function isTodayLogin(lastLoginAt: string) {
|
||||
if (!lastLoginAt) return false
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const today = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return lastLoginAt.slice(0, 10) === today
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
if (t === 'sign') return '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
return t
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
@@ -139,6 +269,13 @@ function handlePage(p: number) {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function handlePageSize(size: number) {
|
||||
if (size === pageSize.value) return
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function openDetail(user: UserItem) {
|
||||
showDetail.value = true
|
||||
detailLoading.value = true
|
||||
@@ -241,14 +378,19 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 当天统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div
|
||||
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
|
||||
title="点击查看执行日志明细"
|
||||
@click="openTodayLogs"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<Activity class="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-gray-400">当天执行总次数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
@@ -311,51 +453,81 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1080px]">
|
||||
<table class="w-full text-sm table-fixed min-w-[1360px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<th class="px-4 py-3 font-medium w-[11%]">学工号</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">真实姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[15%]">设备码</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">可执行/已执行</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">积分/邀请数</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">今天执行状态</th>
|
||||
<th class="px-4 py-3 font-medium w-[12%]">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium text-center w-[24%]">操作</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">学工号/姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[12%]">学校/设备码</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">VIP</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">积分/邀请数</th>
|
||||
<th class="px-4 py-3 font-medium w-[7%]">邀请人</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">今天执行状态</th>
|
||||
<th class="px-4 py-3 font-medium w-[7%]">创建时间</th>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">最后登录</th>
|
||||
<th class="px-4 py-3 font-medium text-center w-[15%]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class="border-t border-gray-50 transition-colors"
|
||||
:class="isTodayLogin(user.lastLoginAt)
|
||||
? 'bg-green-50 hover:bg-green-100/70'
|
||||
: 'hover:bg-gray-50/50'"
|
||||
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
|
||||
>
|
||||
<td class="px-4 py-3 truncate font-mono text-xs">{{ user.studentId }}</td>
|
||||
<td class="px-4 py-3 truncate">{{ user.realName || '--' }}</td>
|
||||
<td class="px-4 py-3 truncate font-mono text-xs">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span v-if="user.mobileCode" class="text-gray-700 truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
|
||||
<span v-else class="text-gray-300">未绑定</span>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
|
||||
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
|
||||
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
|
||||
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
|
||||
<span v-else>未绑定</span>
|
||||
<button
|
||||
v-if="user.mobileCode"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
:title="'复制完整设备码'"
|
||||
@click="copyDeviceCode(user.mobileCode)"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.executedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-if="user.vipStatus === 'active'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
|
||||
:title="`VIP 到期时间:${user.vipExpireAt}`"
|
||||
>
|
||||
VIP · {{ user.vipExpireAt.slice(5, 10) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="user.vipStatus === 'expired'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
|
||||
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
|
||||
>
|
||||
VIP · 已过期
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-300">非VIP</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.invitedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 truncate">
|
||||
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
|
||||
<span v-else class="text-gray-300">--</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
@@ -365,6 +537,10 @@ function todayStatusLabel(s: string) {
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-mono text-xs text-gray-700" :title="user.lastLoginIp || undefined">{{ user.lastLoginIp || '--' }}</p>
|
||||
<p class="text-[10px] text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
@@ -399,10 +575,19 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="total > pageSize"
|
||||
v-if="total > 0"
|
||||
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
|
||||
>
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
|
||||
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
@@ -506,6 +691,12 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<UserPlus class="w-3.5 h-3.5" /> 邀请人
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
|
||||
@@ -530,6 +721,12 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Globe class="w-3.5 h-3.5" /> 最后登录IP
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -583,6 +780,151 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 当天执行日志弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showLogsModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showLogsModal = false" />
|
||||
<div class="relative w-full sm:max-w-3xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
@click="showLogsModal = false"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<input
|
||||
type="date"
|
||||
:value="logsDate"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleLogsDateChange"
|
||||
/>
|
||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in logsStatusOptions"
|
||||
:key="opt.key"
|
||||
class="h-9 px-3 text-xs font-medium transition-colors"
|
||||
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
@click="handleLogsStatusChange(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-w-[180px]">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
||||
<input
|
||||
v-model="logsKeyword"
|
||||
placeholder="搜索用户名/姓名/任务标题"
|
||||
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@keyup.enter="handleLogsSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
|
||||
<Search class="w-3.5 h-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 overflow-y-auto border-t border-gray-100">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="logsLoading" class="p-5 space-y-3">
|
||||
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
|
||||
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">该日期暂无执行记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
|
||||
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="log in logItems"
|
||||
:key="log.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
|
||||
>
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
|
||||
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
|
||||
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
|
||||
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="logsTotal > 0"
|
||||
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
|
||||
>
|
||||
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage <= 1"
|
||||
@click="handleLogsPage(logsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="handleLogsPage(logsPage + 1)"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ onUnmounted(() => {
|
||||
<CheckCircle class="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-1">绑定成功</h2>
|
||||
<p class="text-sm text-gray-400">即将进入首页...</p>
|
||||
<p class="text-sm text-gray-400">即将进入打卡页...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误 / 过期 -->
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Globe, Copy } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
// 原始地址(含参数),在系统浏览器中打开后由路由守卫自动回到原页面
|
||||
const targetUrl = computed(() => {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
const path = redirect.startsWith('/') ? redirect : `/${redirect}`
|
||||
return `${window.location.origin}${path}`
|
||||
})
|
||||
|
||||
function copyLink() {
|
||||
const text = targetUrl.value
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => message.success('链接已复制,请在浏览器中打开'))
|
||||
.catch(() => fallbackCopy(text))
|
||||
return
|
||||
}
|
||||
fallbackCopy(text)
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
message.success('链接已复制,请在浏览器中打开')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制地址栏链接')
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guide-wrap">
|
||||
<div class="guide-card">
|
||||
<!-- 右上角:模拟浏览器菜单 + 手指指向(业界通用引导样式)-->
|
||||
<div class="top-hint" aria-hidden="true">
|
||||
<div class="hand">👆</div>
|
||||
<div class="fake-menu">···</div>
|
||||
</div>
|
||||
|
||||
<div class="icon-circle">
|
||||
<Globe :size="34" :stroke-width="1.8" />
|
||||
</div>
|
||||
<h2 class="title">请在浏览器中打开</h2>
|
||||
<p class="desc">
|
||||
请点击<b>右上角 ···</b>(或 <b>⋮</b>)<br />
|
||||
选择「<b>在浏览器中打开</b>」
|
||||
</p>
|
||||
<Button class="open-btn" size="lg" @click="copyLink">
|
||||
<Copy :size="18" />
|
||||
复制链接去浏览器打开
|
||||
</Button>
|
||||
<p class="tip">如果上方按钮无效,也可以手动复制地址栏链接</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(160deg, #eef4ff 0%, #f7f9fc 60%, #eefaf3 100%);
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 56px 24px 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 12px 40px rgba(31, 58, 122, 0.12);
|
||||
}
|
||||
|
||||
/* 右上角菜单图标 + 手指:手指位于菜单左下方、指向右上角 */
|
||||
.top-hint {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fake-menu {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: #f3f4f6;
|
||||
border: 1.5px solid #d1d5db;
|
||||
color: #6b7280;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hand {
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
margin-right: 6px;
|
||||
margin-bottom: -2px;
|
||||
transform: rotate(38deg);
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.18));
|
||||
animation: hand-tap 1.1s ease-in-out infinite;
|
||||
transform-origin: 30% 90%;
|
||||
}
|
||||
|
||||
@keyframes hand-tap {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(38deg) translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(38deg) translate(5px, -5px);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #6b7280;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.desc b {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.open-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
font-size: 15px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone } from 'lucide-vue-next'
|
||||
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import * as authApi from '@/api/auth'
|
||||
import { CPD_PASSWORD_ERROR } from '@/api'
|
||||
@@ -15,6 +15,62 @@ const loading = ref(true)
|
||||
const showNoRunsDialog = ref(false)
|
||||
const noRunsMessage = ref('')
|
||||
|
||||
// ======== 已打卡再次执行确认弹窗 ========
|
||||
const showConfirmExecDialog = ref(false)
|
||||
const confirmExecTask = ref<authApi.TaskInfo | null>(null)
|
||||
|
||||
/** 确认再次打卡:以完全相同的执行规则重新执行该任务 */
|
||||
function confirmExecAgain() {
|
||||
const t = confirmExecTask.value
|
||||
showConfirmExecDialog.value = false
|
||||
confirmExecTask.value = null
|
||||
if (t) executeTask(t, true)
|
||||
}
|
||||
|
||||
// ======== 收藏本站 ========
|
||||
// 浏览器安全限制不允许 JS 直接写入书签/收藏,点击后复制本站网址到剪贴板,
|
||||
// 并按设备类型展示对应浏览器的手动添加步骤(手机:菜单添加书签/收藏或添加到主屏幕;电脑:Ctrl+D)
|
||||
const showBookmarkDialog = ref(false)
|
||||
const bookmarkUrlCopied = ref(false)
|
||||
const isMobileDevice = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||
const siteUrl = window.location.origin
|
||||
|
||||
function openBookmarkDialog() {
|
||||
showBookmarkDialog.value = true
|
||||
copySiteUrl()
|
||||
}
|
||||
|
||||
function copySiteUrl() {
|
||||
const url = window.location.origin
|
||||
bookmarkUrlCopied.value = false
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
bookmarkUrlCopied.value = true
|
||||
}).catch(() => {
|
||||
bookmarkUrlCopied.value = fallbackCopyText(url)
|
||||
})
|
||||
} else {
|
||||
bookmarkUrlCopied.value = fallbackCopyText(url)
|
||||
}
|
||||
}
|
||||
|
||||
/** execCommand 降级复制(不支持 Clipboard API 的环境),返回是否成功 */
|
||||
function fallbackCopyText(text: string): boolean {
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 设备切换弹窗 ========
|
||||
const showDeviceSwitchDialog = ref(false)
|
||||
const deviceSwitchMessage = ref('')
|
||||
@@ -88,9 +144,35 @@ async function removeTaskLog(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 设备码绑定状态(新建任务入口拦截用) ========
|
||||
const deviceCode = ref('')
|
||||
const showBindDeviceDialog = ref(false)
|
||||
|
||||
// 执行成功后的延迟校正刷新定时器(今日校园侧状态同步延迟时兜底)
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
// ======== 切回页面自动刷新 ========
|
||||
// 覆盖"在今日校园 App 里打卡后切回本站"的场景:列表/匹配状态自动刷新,无需手动重进页面
|
||||
let lastVisibleRefreshAt = 0
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
// 节流 15 秒:避免浏览器内频繁切换标签页时重复请求今日校园接口
|
||||
const now = Date.now()
|
||||
if (now - lastVisibleRefreshAt < 15000) return
|
||||
lastVisibleRefreshAt = now
|
||||
loadTasks()
|
||||
loadTaskLogs()
|
||||
}
|
||||
|
||||
// ======== 初始化 ========
|
||||
onMounted(async () => {
|
||||
// MobileLayout 已加载用户信息并处理绑设备检查
|
||||
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
} catch {
|
||||
// 拉取失败不阻塞页面
|
||||
}
|
||||
// 这里直接加载任务数据和日志
|
||||
await loadTasks()
|
||||
await loadTaskLogs()
|
||||
@@ -98,10 +180,13 @@ onMounted(async () => {
|
||||
|
||||
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
|
||||
window.addEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
})
|
||||
|
||||
// cpdaily 密码错误处理:弹出重新登录确认框
|
||||
@@ -142,6 +227,11 @@ async function loadTasks() {
|
||||
|
||||
// ======== Step 0: 选择任务标题 ========
|
||||
async function startCreateTask() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入创建流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
showStep.value = true
|
||||
step.value = 0
|
||||
stepTitle.value = '选择签到任务'
|
||||
@@ -261,6 +351,16 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign')
|
||||
const items = isEdit ? editFormItems.value : formItems.value
|
||||
|
||||
// 签到二维码仅用于后台解析 uuid,无需上传到今日校园 OBS,本地预览即可
|
||||
if (items[index].colName === 'qrCode') {
|
||||
const qrReader = new FileReader()
|
||||
qrReader.onload = (e) => {
|
||||
items[index].content = (e.target?.result as string) || ''
|
||||
}
|
||||
qrReader.readAsDataURL(file)
|
||||
return
|
||||
}
|
||||
|
||||
// 先显示本地预览
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
@@ -284,13 +384,20 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
const policy = policyJson.data
|
||||
|
||||
// Step 2: 浏览器直传到今日校园 OBS(文件不经过我们的服务器)
|
||||
// 使用接口返回的 formData 字段集原样提交:签到为 q-ak/q-key-time/q-signature 风格,
|
||||
// 查寝为 AccessKeyId/signature 风格,字段不同必须按接口返回拼装
|
||||
const obsForm = new FormData()
|
||||
obsForm.append('x-obs-acl', 'public-read')
|
||||
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
|
||||
obsForm.append('key', policy.fileName || policy.FileName)
|
||||
obsForm.append('signature', policy.signature || policy.Signature)
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
const fd = policy.formData || policy.FormData
|
||||
if (fd && typeof fd === 'object' && Object.keys(fd).length) {
|
||||
for (const [k, v] of Object.entries(fd)) obsForm.append(k, String(v))
|
||||
} else {
|
||||
obsForm.append('x-obs-acl', 'public-read')
|
||||
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
|
||||
obsForm.append('key', policy.fileName || policy.FileName)
|
||||
obsForm.append('signature', policy.signature || policy.Signature)
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
}
|
||||
obsForm.append('file', file)
|
||||
|
||||
const obsRes = await fetch(policy.host || policy.Host, {
|
||||
@@ -330,6 +437,19 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
}
|
||||
|
||||
// ======== 保存表单(创建流程) ========
|
||||
// ======== 表单完整性:必填项为空时提交必然失败(如签到地点未配置 → 服务端报"纬度不能为空") ========
|
||||
function missingRequiredFields(items: authApi.FormItem[] | null | undefined): string[] {
|
||||
if (!items?.length) return []
|
||||
return items
|
||||
.filter(i => i.required && i.show !== false && !String(i.content ?? '').trim())
|
||||
.map(i => i.title || i.colName)
|
||||
}
|
||||
|
||||
function parseFormItems(formData?: string | null): authApi.FormItem[] {
|
||||
if (!formData) return []
|
||||
try { return JSON.parse(formData) as authApi.FormItem[] } catch { return [] }
|
||||
}
|
||||
|
||||
// ======== 范围外签到原因必填校验 ========
|
||||
function validateMalpositionReason(items: authApi.FormItem[]): boolean {
|
||||
const mal = items.find(i => i.colName === 'isMalposition')
|
||||
@@ -350,6 +470,10 @@ async function saveForm() {
|
||||
await authApi.saveTaskForm(formTaskId.value, formItems.value)
|
||||
showStep.value = false
|
||||
await loadTasks()
|
||||
const missing = missingRequiredFields(formItems.value)
|
||||
if (missing.length) {
|
||||
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message || e.message || '保存失败')
|
||||
} finally {
|
||||
@@ -397,6 +521,10 @@ async function saveEdit() {
|
||||
await authApi.saveTaskForm(editTask.value.id, editFormItems.value)
|
||||
showEditModal.value = false
|
||||
await loadTasks()
|
||||
const missing = missingRequiredFields(editFormItems.value)
|
||||
if (missing.length) {
|
||||
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message || e.message || '保存失败')
|
||||
} finally {
|
||||
@@ -409,9 +537,22 @@ function isDeviceSwitchError(msg: string): boolean {
|
||||
return msg.includes('更换手机') || msg.includes('频繁切换设备') || msg.includes('使用了新的设备') || msg.includes('设备打卡失败') || msg.includes('手机授权')
|
||||
}
|
||||
|
||||
function executeTask(task: authApi.TaskInfo) {
|
||||
function executeTask(task: authApi.TaskInfo, confirmed = false) {
|
||||
if (executingId.value) return
|
||||
if (task.taskStatus === 'success') return
|
||||
// 当天已成功打卡:默认拦截并弹出确认,用户确认后(confirmed=true)以完全相同的规则再次执行
|
||||
if (task.taskStatus === 'success' && !confirmed) {
|
||||
confirmExecTask.value = task
|
||||
showConfirmExecDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 表单完整性预检:必填项为空时直接引导去编辑补全(后端同样会拦截,此处提示更快更直观)
|
||||
const missing = missingRequiredFields(parseFormItems(task.formData))
|
||||
if (missing.length) {
|
||||
message.warning(`签到信息未配置完整,缺少:${missing.join('、')},请先点「编辑」补全`)
|
||||
openEditModal(task)
|
||||
return
|
||||
}
|
||||
|
||||
// 清除旧的错误信息
|
||||
execStatus.value = ''
|
||||
@@ -439,8 +580,22 @@ function executeTask(task: authApi.TaskInfo) {
|
||||
onSuccess: () => {
|
||||
execStatus.value = 'success'
|
||||
execMessage.value = '签到成功!'
|
||||
// 本地乐观更新:立即把该任务标记为当天已完成。
|
||||
// 列表接口需实时请求今日校园,可能较慢或状态暂时滞后,先本地刷新保证用户马上看到正确状态
|
||||
const t = tasks.value.find(x => x.id === task.id)
|
||||
if (t) {
|
||||
t.taskStatus = 'success'
|
||||
t.errorMessage = ''
|
||||
t.matchStatus = 'matched_signed'
|
||||
}
|
||||
loadTasks()
|
||||
loadTaskLogs()
|
||||
// 今日校园侧状态同步有延迟:稍后再拉一次列表校正匹配状态(如被判定为未签到/未匹配的兜底修正)
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null
|
||||
loadTasks()
|
||||
}, 6000)
|
||||
setTimeout(() => {
|
||||
executingId.value = null
|
||||
execStatus.value = ''
|
||||
@@ -536,15 +691,17 @@ function statusLabel(status: string) {
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 匹配状态背景色
|
||||
function matchBgClass(status: string) {
|
||||
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
|
||||
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
|
||||
function matchBgClass(task: authApi.TaskInfo) {
|
||||
if (task.taskStatus === 'success') return 'bg-green-50/60'
|
||||
const map: Record<string, string> = {
|
||||
matched_unsigned: 'bg-blue-50/60',
|
||||
matched_signed: 'bg-green-50/60',
|
||||
no_match: 'bg-amber-50/60',
|
||||
check_failed: 'bg-gray-50',
|
||||
}
|
||||
return map[status] || 'bg-gray-50'
|
||||
return map[task.matchStatus] || 'bg-gray-50'
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
@@ -714,6 +871,7 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-guide="home-create-task"
|
||||
@click="startCreateTask"
|
||||
class="w-full flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
@@ -747,7 +905,7 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
|
||||
<!-- 任务列表(不显示 WID) -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div data-guide="home-task-list" class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">签到任务</h3>
|
||||
|
||||
<div v-if="tasks.length > 0" class="space-y-2">
|
||||
@@ -755,7 +913,7 @@ function getGPSLocation(source: string) {
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg transition-colors"
|
||||
:class="matchBgClass(task.matchStatus)"
|
||||
:class="matchBgClass(task)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -763,8 +921,9 @@ function getGPSLocation(source: string) {
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<!-- 每任务只显示一个最相关的状态标签 -->
|
||||
<template v-if="task.matchStatus === 'matched_signed'">
|
||||
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
|
||||
今日校园侧签到状态同步有延迟时 matchStatus 可能仍是未签到,应以本地成功为准 -->
|
||||
<template v-if="task.taskStatus === 'success' || task.matchStatus === 'matched_signed'">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
|
||||
</template>
|
||||
<template v-else-if="task.matchStatus === 'matched_unsigned'">
|
||||
@@ -801,6 +960,7 @@ function getGPSLocation(source: string) {
|
||||
<!-- 执行按钮:pending 或 fail 时可执行 -->
|
||||
<button
|
||||
v-if="task.taskStatus === 'pending' || task.taskStatus === 'fail'"
|
||||
data-guide="home-task-exec"
|
||||
@click="executeTask(task)"
|
||||
:disabled="executingId !== null"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white transition-colors"
|
||||
@@ -809,12 +969,17 @@ function getGPSLocation(source: string) {
|
||||
<Play v-if="executingId !== task.id" class="w-4 h-4" />
|
||||
<LoaderCircle v-else class="w-4 h-4 animate-spin" />
|
||||
</button>
|
||||
<div
|
||||
<!-- 已完成:对勾可点击,弹窗确认后可再次打卡(走相同执行规则) -->
|
||||
<button
|
||||
v-if="task.taskStatus === 'success'"
|
||||
class="w-8 h-8 flex items-center justify-center"
|
||||
@click="executeTask(task)"
|
||||
:disabled="executingId !== null"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg transition-colors disabled:opacity-60 hover:bg-green-50"
|
||||
:title="executingId === task.id ? '执行中…' : '已打卡,点击可再次打卡'"
|
||||
>
|
||||
<CheckCircle class="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<LoaderCircle v-if="executingId === task.id" class="w-4 h-4 animate-spin" />
|
||||
<CheckCircle v-else class="w-5 h-5 text-green-500" />
|
||||
</button>
|
||||
<!-- 删除 -->
|
||||
<button
|
||||
@click="removeTask(task.id)"
|
||||
@@ -829,7 +994,7 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center py-8">
|
||||
<div v-else data-guide="home-task-empty" class="flex items-center justify-center py-8">
|
||||
<div class="text-center">
|
||||
<ListTodo class="w-8 h-8 text-gray-200 mx-auto mb-2" />
|
||||
<p class="text-xs text-gray-400">暂无签到任务</p>
|
||||
@@ -890,6 +1055,15 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
|
||||
<button
|
||||
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-amber-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
|
||||
@click="openBookmarkDialog"
|
||||
>
|
||||
<Star class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="bookmark-vtext">收藏本站</span>
|
||||
</button>
|
||||
|
||||
<!-- ======== 创建流程 - 步骤弹窗 ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -1255,6 +1429,93 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 收藏本站指引弹窗 ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showBookmarkDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" @click="showBookmarkDialog = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center shrink-0">
|
||||
<Star class="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">收藏本站</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">添加到书签/主屏幕,下次一键直达</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-xl p-4 text-sm text-gray-600 leading-relaxed">
|
||||
<!-- 手机浏览器:菜单添加书签/收藏,或添加到主屏幕 -->
|
||||
<template v-if="isMobileDevice">
|
||||
<p class="font-medium text-gray-700 mb-2">手机浏览器操作步骤:</p>
|
||||
<ol class="space-y-1.5 list-decimal list-inside">
|
||||
<li>点击浏览器菜单(<span class="font-semibold">⋮</span> 或分享图标)</li>
|
||||
<li>选择「<span class="font-semibold">添加书签</span>」/「<span class="font-semibold">收藏</span>」,或「<span class="font-semibold">添加到主屏幕</span>」</li>
|
||||
<li>下次从书签或桌面图标即可直接打开本站</li>
|
||||
</ol>
|
||||
</template>
|
||||
<!-- 电脑浏览器:快捷键 -->
|
||||
<template v-else>
|
||||
<p class="font-medium text-gray-700 mb-2">电脑浏览器:</p>
|
||||
<p>按 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">Ctrl</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>(Mac 为 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">⌘</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>)即可添加书签</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 网址复制状态 -->
|
||||
<p class="mt-3 text-xs flex items-center gap-1.5" :class="bookmarkUrlCopied ? 'text-green-600' : 'text-gray-400'">
|
||||
<CheckCircle v-if="bookmarkUrlCopied" class="w-3.5 h-3.5" />
|
||||
<span v-if="bookmarkUrlCopied">本站网址已复制到剪贴板,可在添加收藏时粘贴使用</span>
|
||||
<span v-else>本站网址:{{ siteUrl }}</span>
|
||||
</p>
|
||||
|
||||
<Button class="mt-4 w-full h-10 text-sm font-medium" @click="showBookmarkDialog = false">
|
||||
我知道了
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 已打卡再次执行确认弹窗 ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showConfirmExecDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" @click="showConfirmExecDialog = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle class="w-7 h-7 text-green-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">您当前已经打卡</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
是否再次打卡?再次执行与正常执行规则一致,成功后将扣除一次签到次数。
|
||||
</p>
|
||||
<div class="mt-5 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showConfirmExecDialog = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="confirmExecAgain"
|
||||
>
|
||||
再次打卡
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 无可用签到次数弹窗 ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -1293,6 +1554,38 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showBindDeviceDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40" @click="showBindDeviceDialog = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone class="w-7 h-7 text-indigo-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
请先在个人中心绑定设备码,绑定后才能创建和执行签到任务
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showBindDeviceDialog = false; router.push('/profile')"
|
||||
>
|
||||
去绑定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 操作频繁弹窗(限流) ======== -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -1378,3 +1671,11 @@ function getGPSLocation(source: string) {
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
|
||||
.bookmark-vtext {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText } from 'lucide-vue-next'
|
||||
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -27,9 +27,156 @@ const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const captchaCode = ref('')
|
||||
const loadingCaptcha = ref(false)
|
||||
const captchaError = ref('')
|
||||
|
||||
const logging = ref(false)
|
||||
|
||||
// 多因子认证(MFA)Dialog
|
||||
const showMFADialog = ref(false)
|
||||
const mfaToken = ref('')
|
||||
const mfaMessage = ref('')
|
||||
const mfaMethods = ref<authApi.MFAAuthMethod[]>([]) // 后端返回的全部可用验证方式
|
||||
const mfaSelectedType = ref('') // 当前选中的验证方式(reAuthType)
|
||||
const mfaStage = ref<'select' | 'input'>('select') // select=选择验证方式,input=输入验证码
|
||||
const mfaSending = ref(false) // 发送验证码中
|
||||
const mfaCode = ref('')
|
||||
const mfaSubmitting = ref(false)
|
||||
const mfaCountdown = ref(0) // MFA 会话剩余秒数
|
||||
let mfaTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startMFACountdown(seconds: number) {
|
||||
stopMFACountdown()
|
||||
mfaCountdown.value = seconds
|
||||
mfaTimer = setInterval(() => {
|
||||
if (mfaCountdown.value > 0) {
|
||||
mfaCountdown.value--
|
||||
} else {
|
||||
stopMFACountdown()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopMFACountdown() {
|
||||
if (mfaTimer) {
|
||||
clearInterval(mfaTimer)
|
||||
mfaTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopMFACountdown)
|
||||
|
||||
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
|
||||
function openMFADialog(
|
||||
token: string,
|
||||
msg: string,
|
||||
expiresIn: number,
|
||||
methods?: authApi.MFAAuthMethod[],
|
||||
defaultType?: string
|
||||
) {
|
||||
mfaToken.value = token
|
||||
mfaMessage.value = msg || '请选择验证方式获取验证码'
|
||||
mfaMethods.value = methods && methods.length > 0 ? methods : []
|
||||
// 默认选中:页面 default 标记 > 后端给的 defaultType > 列表首项
|
||||
mfaSelectedType.value =
|
||||
mfaMethods.value.find((m) => m.default)?.type || defaultType || mfaMethods.value[0]?.type || ''
|
||||
// 无方式列表时(理论上仅兼容旧后端)跳过选择阶段,直接进入验证码输入
|
||||
mfaStage.value = mfaMethods.value.length > 0 ? 'select' : 'input'
|
||||
mfaCode.value = ''
|
||||
showMFADialog.value = true
|
||||
startMFACountdown(expiresIn > 0 ? expiresIn : 600)
|
||||
}
|
||||
|
||||
// 选择验证方式
|
||||
function handleMFASelect(type: string) {
|
||||
mfaSelectedType.value = type
|
||||
}
|
||||
|
||||
// 发送 MFA 验证码(用户选定验证方式后)
|
||||
async function handleMFASend() {
|
||||
if (!mfaSelectedType.value) {
|
||||
message.info('请选择验证方式')
|
||||
return
|
||||
}
|
||||
mfaSending.value = true
|
||||
try {
|
||||
const res = await authApi.sendMFACode({
|
||||
mfaToken: mfaToken.value,
|
||||
reAuthType: mfaSelectedType.value,
|
||||
})
|
||||
const body = res.data
|
||||
if (body?.code === 0) {
|
||||
// 发送成功:进入验证码输入阶段
|
||||
mfaMessage.value = body?.message || '验证码已发送,请查收后输入'
|
||||
mfaStage.value = 'input'
|
||||
mfaCode.value = ''
|
||||
} else if (body?.code === 4283) {
|
||||
// 验证会话过期:关闭弹窗,引导重新登录
|
||||
stopMFACountdown()
|
||||
showMFADialog.value = false
|
||||
message.error(body?.message || '验证会话已过期,请重新登录')
|
||||
} else {
|
||||
// 4284 发送失败等:弹窗留在选择界面,可重试或更换验证方式
|
||||
message.error(body?.message || '验证码发送失败,请重试')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || '验证码发送失败,请重试')
|
||||
} finally {
|
||||
mfaSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回验证方式选择(更换方式重新获取验证码)
|
||||
function handleMFAReselect() {
|
||||
mfaStage.value = 'select'
|
||||
mfaCode.value = ''
|
||||
mfaMessage.value = '请选择验证方式获取验证码'
|
||||
}
|
||||
|
||||
// 提交 MFA 验证码
|
||||
async function handleMFAVerify() {
|
||||
const code = mfaCode.value.trim()
|
||||
if (!code) {
|
||||
message.info('请输入验证码')
|
||||
return
|
||||
}
|
||||
mfaSubmitting.value = true
|
||||
try {
|
||||
const res = await authApi.verifyMFA({ mfaToken: mfaToken.value, code })
|
||||
const body = res.data
|
||||
const data = body?.data
|
||||
if (body?.code === 0 && data?.token?.accessToken) {
|
||||
// 验证成功,进入登录态
|
||||
stopMFACountdown()
|
||||
showMFADialog.value = false
|
||||
localStorage.setItem('access_token', data.token.accessToken)
|
||||
localStorage.setItem('refresh_token', data.token.refreshToken)
|
||||
message.success('登录成功')
|
||||
router.replace('/home')
|
||||
} else if (body?.code === 4283) {
|
||||
// 验证会话过期:关闭弹窗,引导重新登录
|
||||
stopMFACountdown()
|
||||
showMFADialog.value = false
|
||||
message.error(body?.message || '验证会话已过期,请重新登录')
|
||||
} else {
|
||||
// 验证码错误(4282)等:输入态保留,清空输入可重试
|
||||
message.error(body?.message || '验证失败,请重试')
|
||||
mfaCode.value = ''
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || '验证失败,请重试'
|
||||
message.error(msg)
|
||||
mfaCode.value = ''
|
||||
} finally {
|
||||
mfaSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeMFADialog() {
|
||||
showMFADialog.value = false
|
||||
stopMFACountdown()
|
||||
message.info('如需登录请重新提交')
|
||||
}
|
||||
|
||||
// 登录失败 Dialog
|
||||
const showLoginFailDialog = ref(false)
|
||||
const loginErrorMessage = ref('')
|
||||
@@ -42,6 +189,7 @@ const reportCaptchaImage = ref('')
|
||||
const reportCaptchaCode = ref('')
|
||||
const reportLoadingCaptcha = ref(false)
|
||||
const reportSubmitting = ref(false)
|
||||
const reportCaptchaError = ref('')
|
||||
|
||||
// 联系邮箱:前缀 + 下拉选择国内邮箱后缀
|
||||
const reportEmailPrefix = ref('')
|
||||
@@ -112,14 +260,25 @@ function handleBlur() {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
|
||||
function captchaFailMsg(e: unknown): string {
|
||||
const err = e as { response?: { data?: { message?: string } } }
|
||||
return err?.response?.data?.message || '验证码加载失败,请点击重试'
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
loadingCaptcha.value = true
|
||||
captchaError.value = ''
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
captchaId.value = res.data?.data?.captchaId || ''
|
||||
captchaImage.value = res.data?.data?.imageBase64 || ''
|
||||
} catch {
|
||||
} catch (e) {
|
||||
captchaId.value = ''
|
||||
captchaImage.value = ''
|
||||
const msg = captchaFailMsg(e)
|
||||
captchaError.value = msg
|
||||
message.error(msg)
|
||||
} finally {
|
||||
loadingCaptcha.value = false
|
||||
}
|
||||
@@ -155,10 +314,25 @@ async function handleLogin() {
|
||||
params.inviteCode = inviteCode.value.trim()
|
||||
}
|
||||
const res = await authApi.login(params)
|
||||
const data = res.data?.data
|
||||
const body: any = res.data
|
||||
|
||||
// 多因子认证:专用业务码 4281(HTTP 200),弹出「选择验证方式」弹窗而非错误弹窗
|
||||
if (body?.code === 4281 && body?.data?.mfaToken) {
|
||||
openMFADialog(
|
||||
body.data.mfaToken,
|
||||
body.message,
|
||||
body.data?.expiresIn || 0,
|
||||
body.data?.authMethods,
|
||||
body.data?.defaultType
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const data = body?.data
|
||||
if (data?.token?.accessToken) {
|
||||
localStorage.setItem('access_token', data.token.accessToken)
|
||||
localStorage.setItem('refresh_token', data.token.refreshToken)
|
||||
// 新手引导:由 MobileLayout 按引导版本号控制展示(新老用户统一),此处无需标记
|
||||
message.success('登录成功')
|
||||
router.replace('/home')
|
||||
} else {
|
||||
@@ -167,11 +341,17 @@ async function handleLogin() {
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '登录失败'
|
||||
const httpStatus = e?.response?.status || 0
|
||||
const data = e?.response?.data?.data || {}
|
||||
|
||||
// 多因子认证:触发 MFA,弹出「选择验证方式」Dialog(兼容旧 needMFA 标记)
|
||||
if (data?.needMFA && data?.mfaToken) {
|
||||
openMFADialog(data.mfaToken, msg, data?.expiresIn || 0, data?.authMethods, data?.defaultType)
|
||||
return
|
||||
}
|
||||
|
||||
// 401(密码错误等)弹出 Dialog 引导用户操作
|
||||
if (httpStatus === 401) {
|
||||
const url = e?.response?.data?.data?.schoolUrl || ''
|
||||
schoolUrl.value = url
|
||||
schoolUrl.value = data?.schoolUrl || ''
|
||||
loginErrorMessage.value = msg
|
||||
showLoginFailDialog.value = true
|
||||
} else {
|
||||
@@ -216,12 +396,17 @@ function handleSubmitTicket() {
|
||||
|
||||
async function loadReportCaptcha() {
|
||||
reportLoadingCaptcha.value = true
|
||||
reportCaptchaError.value = ''
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
reportCaptchaId.value = res.data?.data?.captchaId || ''
|
||||
reportCaptchaImage.value = res.data?.data?.imageBase64 || ''
|
||||
} catch {
|
||||
} catch (e) {
|
||||
reportCaptchaId.value = ''
|
||||
reportCaptchaImage.value = ''
|
||||
const msg = captchaFailMsg(e)
|
||||
reportCaptchaError.value = msg
|
||||
message.error(msg)
|
||||
} finally {
|
||||
reportLoadingCaptcha.value = false
|
||||
}
|
||||
@@ -380,6 +565,9 @@ async function handleReportSubmit() {
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
@@ -411,6 +599,133 @@ async function handleReportSubmit() {
|
||||
<p class="text-xs text-gray-300">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showMFADialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center px-6"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeMFADialog" />
|
||||
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
|
||||
<div class="p-6">
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<ShieldCheck class="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
|
||||
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 阶段一:选择验证方式 ===== -->
|
||||
<template v-if="mfaStage === 'select'">
|
||||
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">
|
||||
{{ mfaMessage || '请选择验证方式获取验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证方式列表 -->
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="m in mfaMethods"
|
||||
:key="m.type"
|
||||
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
|
||||
:class="
|
||||
mfaSelectedType === m.type
|
||||
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
|
||||
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
|
||||
"
|
||||
:disabled="mfaSending"
|
||||
@click="handleMFASelect(m.type)"
|
||||
>
|
||||
<span>{{ m.name }}</span>
|
||||
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 发送验证码 -->
|
||||
<Button
|
||||
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
|
||||
@click="handleMFASend"
|
||||
>
|
||||
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
|
||||
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- ===== 阶段二:输入验证码 ===== -->
|
||||
<template v-else>
|
||||
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
|
||||
<div class="relative">
|
||||
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="mfaCode"
|
||||
placeholder="请输入收到的验证码"
|
||||
class="pl-9 pr-9 h-10 text-sm tracking-widest"
|
||||
maxlength="8"
|
||||
@keyup.enter="handleMFAVerify"
|
||||
/>
|
||||
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
|
||||
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<Button
|
||||
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||
:disabled="mfaSubmitting || mfaCountdown <= 0"
|
||||
@click="handleMFAVerify"
|
||||
>
|
||||
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
|
||||
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
|
||||
<template v-else>验证并登录</template>
|
||||
</Button>
|
||||
|
||||
<!-- 更换验证方式 -->
|
||||
<button
|
||||
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
|
||||
@click="handleMFAReselect"
|
||||
>
|
||||
更换验证方式
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 取消 -->
|
||||
<button
|
||||
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@click="closeMFADialog"
|
||||
>
|
||||
重新登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- ======== 登录失败 Dialog ======== -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
@@ -449,7 +764,7 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">进入学校登录地址自行找回</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -563,6 +878,9 @@ async function handleReportSubmit() {
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare } from 'lucide-vue-next'
|
||||
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as authApi from '@/api/auth'
|
||||
import type { UserInfo } from '@/api/auth'
|
||||
import type { UserInfo, MyInviteInfo } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
@@ -20,6 +20,29 @@ const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const generatingInvite = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
// 邀请奖励规则(来自系统配置,非写死)
|
||||
const inviterRewardPerInvite = ref<number | null>(null)
|
||||
const inviteeRewardPerInvite = ref<number | null>(null)
|
||||
|
||||
// 格式化奖励数字:整数不带小数点(3 → "3"),小数保留原样(0.5 → "0.5")
|
||||
function fmtReward(v: number | null): string {
|
||||
if (v === null) return ''
|
||||
const n = Math.round(v * 10) / 10
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
function applyInviteInfo(data: MyInviteInfo) {
|
||||
inviteCode.value = data.inviteCode
|
||||
inviteReward.value = data.inviteReward
|
||||
invitedCount.value = data.invitedCount
|
||||
canRedeem.value = data.canRedeem
|
||||
if (typeof data.inviterRewardPerInvite === 'number') {
|
||||
inviterRewardPerInvite.value = data.inviterRewardPerInvite
|
||||
}
|
||||
if (typeof data.inviteeRewardPerInvite === 'number') {
|
||||
inviteeRewardPerInvite.value = data.inviteeRewardPerInvite
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请链接 URL
|
||||
const inviteUrl = computed(() => {
|
||||
@@ -33,18 +56,41 @@ const showRedeemModal = ref(false)
|
||||
const redeemCodeText = ref('')
|
||||
const redeeming = ref(false)
|
||||
const redeemError = ref('')
|
||||
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
|
||||
const redeemKind = ref<'count' | 'vip'>('count')
|
||||
// VIP 信息
|
||||
const isVip = ref(false)
|
||||
const vipExpireAt = ref('')
|
||||
// 验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captchaError = ref('')
|
||||
|
||||
// VIP 到期时间短格式(2026-10-08)
|
||||
const vipExpireShort = computed(() => {
|
||||
if (!vipExpireAt.value) return ''
|
||||
return vipExpireAt.value.slice(0, 10)
|
||||
})
|
||||
|
||||
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
|
||||
function captchaFailMsg(e: unknown): string {
|
||||
const err = e as { response?: { data?: { message?: string } } }
|
||||
return err?.response?.data?.message || '验证码加载失败,请点击重试'
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
captchaError.value = ''
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
captchaId.value = res.data?.data?.captchaId || ''
|
||||
captchaImage.value = res.data?.data?.imageBase64 || ''
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
captchaId.value = ''
|
||||
captchaImage.value = ''
|
||||
const msg = captchaFailMsg(e)
|
||||
captchaError.value = msg
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +108,21 @@ onMounted(async () => {
|
||||
// 加载邀请信息
|
||||
try {
|
||||
const inviteRes = await authApi.getMyInviteInfo()
|
||||
inviteCode.value = inviteRes.data.data.inviteCode
|
||||
inviteReward.value = inviteRes.data.data.inviteReward
|
||||
invitedCount.value = inviteRes.data.data.invitedCount
|
||||
canRedeem.value = inviteRes.data.data.canRedeem
|
||||
applyInviteInfo(inviteRes.data.data)
|
||||
} catch {
|
||||
// invite info not yet generated
|
||||
}
|
||||
} catch {
|
||||
message.error('获取用户信息失败')
|
||||
}
|
||||
// 加载 VIP 信息
|
||||
try {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
} catch {
|
||||
// VIP 信息加载失败不阻塞页面
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -84,8 +135,43 @@ function goRebind() {
|
||||
router.push('/bind-device')
|
||||
}
|
||||
|
||||
// ---- 随机绑定设备码(连续两次风险确认) ----
|
||||
// 0=关闭 1=第一次确认 2=第二次确认
|
||||
const randBindStep = ref(0)
|
||||
const randBinding = ref(false)
|
||||
|
||||
function openRandomBind() {
|
||||
randBindStep.value = 1
|
||||
}
|
||||
|
||||
function closeRandomBind() {
|
||||
randBindStep.value = 0
|
||||
}
|
||||
|
||||
async function confirmRandomBind() {
|
||||
if (randBindStep.value === 1) {
|
||||
// 第一次确认 → 进入第二次确认
|
||||
randBindStep.value = 2
|
||||
return
|
||||
}
|
||||
randBinding.value = true
|
||||
try {
|
||||
const res = await authApi.randomBindDevice()
|
||||
if (userInfo.value) {
|
||||
userInfo.value.mobileCode = res.data.data.deviceCode
|
||||
}
|
||||
randBindStep.value = 0
|
||||
message.success('随机设备码绑定成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '绑定失败')
|
||||
} finally {
|
||||
randBinding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 兑换弹窗 ----
|
||||
function openRedeemModal() {
|
||||
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
showRedeemModal.value = true
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
@@ -97,6 +183,17 @@ function closeRedeemModal() {
|
||||
showRedeemModal.value = false
|
||||
}
|
||||
|
||||
// 刷新 VIP 信息(兑换 VIP CDK 后调用)
|
||||
async function reloadVipInfo() {
|
||||
try {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem() {
|
||||
if (!redeemCodeText.value.trim()) {
|
||||
redeemError.value = '请输入兑换码'
|
||||
@@ -111,12 +208,19 @@ async function handleRedeem() {
|
||||
redeeming.value = true
|
||||
try {
|
||||
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
|
||||
// 一次性消耗全部剩余次数
|
||||
const totalRuns = res.data.data.totalRuns
|
||||
if (userInfo.value) {
|
||||
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
|
||||
const data = res.data.data
|
||||
if (data.type === 'vip') {
|
||||
// VIP CDK:延长 VIP 到期时间
|
||||
await reloadVipInfo()
|
||||
message.success(`VIP 兑换成功!已延长至 ${data.vipExpireAt || ''}`)
|
||||
} else {
|
||||
// 次数 CDK:一次性消耗全部剩余次数
|
||||
const totalRuns = data.totalRuns ?? 0
|
||||
if (userInfo.value) {
|
||||
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
|
||||
}
|
||||
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
|
||||
}
|
||||
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
|
||||
closeRedeemModal()
|
||||
} catch (err: any) {
|
||||
redeemError.value = err.response?.data?.message || '兑换失败'
|
||||
@@ -130,10 +234,7 @@ async function handleGenerateInvite() {
|
||||
generatingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.getMyInvite()
|
||||
inviteCode.value = res.data.data.inviteCode
|
||||
inviteReward.value = res.data.data.inviteReward
|
||||
invitedCount.value = res.data.data.invitedCount
|
||||
canRedeem.value = res.data.data.canRedeem
|
||||
applyInviteInfo(res.data.data)
|
||||
} catch {
|
||||
message.error('生成邀请码失败')
|
||||
} finally {
|
||||
@@ -192,12 +293,34 @@ async function handleRedeemInvite() {
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
|
||||
<div class="w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-hidden ring-4 ring-white/30">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
|
||||
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30">
|
||||
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
|
||||
</div>
|
||||
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
|
||||
|
||||
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
|
||||
<div class="mt-3 flex items-center justify-center gap-2">
|
||||
<span
|
||||
v-if="isVip"
|
||||
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
|
||||
>
|
||||
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
|
||||
</span>
|
||||
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
|
||||
@click="openRedeemModal('vip')"
|
||||
>
|
||||
续费 VIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
@@ -210,13 +333,13 @@ async function handleRedeemInvite() {
|
||||
<ClipboardCheck class="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">可签到次数</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
|
||||
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
|
||||
@click="openRedeemModal"
|
||||
@click="openRedeemModal('count')"
|
||||
>
|
||||
<Gift class="w-3 h-3" />
|
||||
兑换
|
||||
@@ -234,16 +357,22 @@ async function handleRedeemInvite() {
|
||||
<div class="flex items-center gap-1.5">
|
||||
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
|
||||
@click="copyInviteUrl"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
|
||||
<p v-if="inviterRewardPerInvite !== null" class="text-[10px] text-gray-400 mt-1 leading-relaxed">
|
||||
好友通过你的链接注册登录,你可得 <span class="text-amber-500 font-medium">{{ fmtReward(inviterRewardPerInvite) }}</span> 积分、TA 得
|
||||
<span class="text-amber-500 font-medium">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分;满 1 积分可兑换 1 次签到次数
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友注册可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
|
||||
</div>
|
||||
<div v-else class="mt-1">
|
||||
<button
|
||||
data-guide="profile-invite-generate"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
|
||||
:disabled="generatingInvite"
|
||||
@click="handleGenerateInvite"
|
||||
@@ -256,7 +385,7 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邀请信息 + 兑换按钮 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div data-guide="profile-invite-row" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-amber-50 flex items-center justify-center">
|
||||
<Gift class="w-4 h-4 text-amber-500" />
|
||||
@@ -271,6 +400,7 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
<button
|
||||
v-if="inviteCode"
|
||||
data-guide="profile-invite-redeem"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
|
||||
:disabled="!canRedeem || redeemingInvite"
|
||||
@@ -298,32 +428,9 @@ async function handleRedeemInvite() {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 真实姓名 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
|
||||
<User class="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">真实姓名</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ userInfo?.realName || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 学号 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
|
||||
<IdCard class="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">学号</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ userInfo?.studentId || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
|
||||
<!-- 设备码 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
@@ -337,13 +444,22 @@ async function handleRedeemInvite() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
|
||||
@click="openRandomBind"
|
||||
>
|
||||
<Dices class="w-3 h-3" />
|
||||
随机
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,7 +484,9 @@ async function handleRedeemInvite() {
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">兑换签到次数</h3>
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -378,7 +496,7 @@ async function handleRedeemInvite() {
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 兑换码输入 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">兑换码</label>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
|
||||
<Input
|
||||
v-model="redeemCodeText"
|
||||
placeholder="请输入 CDK 兑换码"
|
||||
@@ -404,6 +522,14 @@ async function handleRedeemInvite() {
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div
|
||||
v-else-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
加载中
|
||||
</div>
|
||||
@@ -425,6 +551,66 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<!-- 第一次确认 -->
|
||||
<template v-if="randBindStep === 1">
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
|
||||
<strong>「频繁切换设备」提示,无法正常签到</strong>。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 第二次确认 -->
|
||||
<template v-else>
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
随机后,你现有手机登录 App 执行签到时将提示
|
||||
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
|
||||
需要重新扫码授权绑定才能恢复。
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="randBinding"
|
||||
@click="confirmRandomBind"
|
||||
>
|
||||
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
|
||||
{{ randBinding ? '绑定中...' : '确定随机' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -437,4 +623,72 @@ async function handleRedeemInvite() {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* VIP 徽标:金色渐变 + 反光条扫过动画
|
||||
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
|
||||
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
|
||||
.vip-badge {
|
||||
overflow: hidden;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.65);
|
||||
box-shadow: 0 2px 6px rgba(160, 100, 0, 0.35);
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.vip-badge::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
left: -45%;
|
||||
width: 38%;
|
||||
height: 220%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.85) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: rotate(18deg);
|
||||
animation: vip-shine 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes vip-shine {
|
||||
0% {
|
||||
left: -45%;
|
||||
}
|
||||
55%,
|
||||
100% {
|
||||
left: 135%;
|
||||
}
|
||||
}
|
||||
|
||||
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
}
|
||||
.vip-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
left: -45%;
|
||||
width: 30%;
|
||||
height: 220%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: rotate(18deg);
|
||||
animation: vip-shine 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user