Compare commits

...

3 Commits

Author SHA1 Message Date
becomer 2f20eebccd v1.2.44:销假功能完整落地(后端 SSE 同步执行 + 前端销假页重写),并按真实接口实测修正位置/审批人语义——① 功能:新增 /leave 路由组(pending-list 待销假列表 / approvers 审批人 / execute SSE 执行 / logs 日志 CRUD),cpdaily/leave.go 封装 listOther+detailOther+obtainApproveFlow+report 全链路,cp_leave_logs 落库成败日志,用户级限流(2m×3/1h×6/24h×10),VIP 免扣非 VIP 成功扣 sign_count,图片经 OBS 浏览器直传(taskType=leave);前端 api/leave.ts + LeaveView.vue 重写(销假按钮/日志区/待销假弹窗/销假表单弹窗);② 规定销假位置修正:老版本取 leaveExtends[0](续假位置,实测近乎全空)或注释的 detail.applyLocation(请假提交时定位),均非学校规定位置;实测真实返回 reportPlaces[](address/经纬度/radius 米/设置人),已改为解析并前端列表选择+预填经纬度,用户可在规定位置间切换;③ 多校差异适配:实测 ynu/whit/lzufe 三校样本确认 leaveType.reportApplyFlow 控制销假是否需选审批人(ynu=1,whit/lzufe=0)、enableAttach 控制图片上传(ynu=1,其余=0),前端按开关自适应渲染审批人选择器与图片组件,提交时按需携带 approverWid;④ 解析 bug 修复:leaveType.code 真实为数字(3/12/2),原声明 string 导致 detailOther 全部解析失败(即"规定位置拿不到"的根因),改为 interface{} 承接并同时产出文本/数字双形式;obtainApproveFlow 要求时间精确到秒(yyyy-MM-dd HH:mm:ss),已自动补秒;⑤ 全程只读实测(listOther/detailOther/obtainApproveFlow,未调用任何 /report 提交),新增/重写 4 个单测覆盖开关解析/规定位置/审批人/提交表单,go test ./...、go vet、前端 build、vue-tsc 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 11:44:37 +08:00
becomer 3aee45be80 v1.2.43:滑块验证码改为「激活窗口等待 + 打码值窄序列」策略,推翻 ±12 扫描——① 决定性发现:服务端 open 后存在约 1.3~1.7s 激活延迟,延迟内提交任何值(含真值)都返回 errorCode=0;激活后接受以真值为中心的 ±8 宽区间(实测上缘 +8、下缘 -6~-8,相对打码值),打码平台返回值即为区间中心(误差 0±1),此前观测到的「打码偏差 -8~+9」实为快速扫描跨过激活窗口的落点伪影,并非识别误差;② 策略改造:slider.go 单轮流程改为 取图 → 打码 → 等待至 open+2.5s(抖动裕度)→ 按 v、v+1、v-1 窄序列提交(候选间隔 350ms,首候选通常即命中),全部失败仍换图重试(最多 5 轮),提交次数上界从 125 次降至 15 次;③ 新增激活等待回归测试(mock 复刻 300ms 拒绝窗口,断言等待后首个候选命中且不重发),重写候选命中/枚举耗尽测试(3×5=15 次 verify 上界),新增 v-1 命中用例;④ 真实账号端到端实测 3/3 一次通过(gapX=75/96/122 均 offset=0 直接命中,全程约 4s,302 ST 票据 → 7 cookies),go test ./...、go vet 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 00:29:25 +08:00
becomer 46c8a554ff v1.2.42:华侨大学(hqdx)CAS 滑块验证码登录完整落地——① sign 协议:POST /authserver/common/verifySliderCaptcha.htl 提交 sign=AES-128-CBC-PKCS7(key=openSliderCaptcha 小图 base64 解码末 16 字节 safeSecure、iv=16 位随机、明文=64 位随机前缀+JSON{canvasLength:280,moveLength,tracks}),响应 errorCode=1 通过 / 0 失败,moveLength 为缺口真实值精确整数(±1 无容差),失败不消费图片、仅成功才作废;② 识别与连发策略:打码平台 /slider 识别打码值 v 后在同一张图上按 v、v±1…v±12(±12 窗口 25 候选)顺序连发,任一命中即止,全部失败换图重试(最多 5 轮 + 递增等待);实测打码偏差分布 -8~+9(另偶发识别失败图靠换图覆盖),故窗口取 ±12 留裕度;③ 流程接入:login_cas.go 按配置 sliderIds 分流滑块学校,casLogin 的表单选择器 #pwdLoginDiv 适配 hqu 登录页三表单布局(账密字段),滑块通过后 captcha 留空提交登录;hqdx 特判强制走 CAS(IAP 账密接口 FAIL_UPNOTMATCH 不兼容);④ 新增 slider.go + slider_test.go:sign 加解密/载荷/轨迹校验、候选命中、换图重试、5 图全失败(125 次 verify 上界)mock 全覆盖。实测:华侨大学真实账号端到端登录成功(3.11s,首图 +9 偏差命中 → 302 ST 票据 → CASTGC/JSESSIONID/MOD_AUTH_CAS 等 7 cookies),go test ./...、go vet 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-13 23:46:28 +08:00
15 changed files with 2686 additions and 50 deletions
+8
View File
@@ -88,6 +88,14 @@ 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)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
cdk.Post("/redeem", handler.UserRedeemCDK)
+1 -1
View File
@@ -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)
}
+344
View File
@@ -0,0 +1,344 @@
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,
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)
}
+20
View File
@@ -0,0 +1,20 @@
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
Title string `gorm:"type:varchar(255);not null" json:"title"` // 日志标题(请假类型名 + "销假")
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"` // 学校名称
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"
}
+6
View File
@@ -30,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:
@@ -142,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:
+424
View File
@@ -0,0 +1,424 @@
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
}
}
// 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,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"])
}
}
+23 -9
View File
@@ -106,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
}
}
}
@@ -200,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)
@@ -176,6 +176,11 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
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)
+341
View File
@@ -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>`
+1
View File
@@ -26,6 +26,7 @@ type TaskType string
const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
)
// 各任务类型的 API 基础路径
+189
View File
@@ -0,0 +1,189 @@
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
isSuccess: boolean
errorMessage: string
createdAt: string
updatedAt: 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 })
}
/** 获取销假日志列表 */
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 {
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}/leave/execute`, {
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
View File
@@ -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,
}
}
+622 -40
View File
@@ -1,47 +1,629 @@
<template>
<!-- 销假功能占位页:功能尚在开发中,以可爱风格占位;正式功能上线时替换本页内容。
配色使用 rose/blue/amber 浅色系(globals.css 暗色板已覆盖对应暗色调,自动适配)。 -->
<div class="min-h-full flex flex-col items-center justify-center px-8 relative overflow-hidden bg-gradient-to-b from-rose-50 via-blue-50 to-amber-50">
<!-- 糖果色装饰圆点(错峰闪烁) -->
<div class="absolute top-16 left-10 w-3 h-3 rounded-full bg-rose-300 opacity-60 animate-pulse" />
<div class="absolute top-32 right-12 w-4 h-4 rounded-full bg-blue-300 opacity-50 animate-pulse" style="animation-delay: 0.6s" />
<div class="absolute bottom-32 left-14 w-5 h-5 rounded-full bg-amber-300 opacity-40 animate-pulse" style="animation-delay: 1.1s" />
<div class="absolute bottom-20 right-10 w-2.5 h-2.5 rounded-full bg-rose-300 opacity-50 animate-pulse" style="animation-delay: 1.6s" />
<div class="absolute top-1/2 left-6 w-2 h-2 rounded-full bg-blue-300 opacity-40 animate-pulse" style="animation-delay: 0.3s" />
<div class="absolute top-24 left-1/2 w-2 h-2 rounded-full bg-amber-300 opacity-50 animate-pulse" style="animation-delay: 0.9s" />
<!-- 中央卡片 -->
<div class="relative w-full max-w-xs bg-white/85 backdrop-blur rounded-[2rem] shadow-xl px-8 py-12 flex flex-col items-center text-center">
<!-- 漂浮糖果装饰 -->
<span class="absolute -top-3 -left-2 text-2xl float-slow select-none">🍬</span>
<span class="absolute top-8 -right-3 text-xl float-slow select-none" style="animation-delay: 1s">✨</span>
<span class="absolute -bottom-2 right-8 text-lg float-slow select-none" style="animation-delay: 2s">🍡</span>
<span class="absolute -bottom-1 left-6 text-base float-slow select-none" style="animation-delay: 1.5s">⭐</span>
<!-- 施工中 -->
<div class="text-6xl mb-5 animate-bounce select-none">🚧</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">正在加紧完善中~</h2>
<p class="text-sm text-gray-500 leading-relaxed mb-4">
销假功能正在快马加鞭开发中<br />
敬请期待
</p>
<p class="text-xs text-gray-400">(๑•̀ㅂ•́)و✧</p>
<!-- 销假页:与打卡页布局一致 —— 上方销假按钮卡片,下方销假日志列表。
销假为同步执行(无任务列表):点击销假按钮 → 选择待销假记录 → 填写位置/图片 → 立即销假。 -->
<div class="p-4 space-y-4">
<!-- 销假入口卡片 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-semibold text-gray-900">请假销假</p>
<p class="text-xs text-gray-400 mt-0.5">请假结束或返校后,一键完成销假</p>
</div>
<div class="w-10 h-10 rounded-full bg-rose-50 flex items-center justify-center">
<CalendarCheck class="w-5 h-5 text-rose-500" />
</div>
</div>
<button
@click="openPendingModal"
:disabled="executing"
class="w-full flex items-center justify-center gap-2 bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
>
<CalendarCheck class="w-4 h-4" />
销假
</button>
</div>
<!-- 销假日志列表 -->
<div 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="loadingLogs" class="flex items-center justify-center py-6">
<LoaderCircle class="w-5 h-5 text-rose-400 animate-spin" />
</div>
<div v-else-if="leaveLogs.length > 0" class="space-y-1.5">
<div
v-for="log in leaveLogs.slice(0, 10)"
:key="log.id"
class="flex items-center justify-between p-2.5 rounded-lg bg-gray-50"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="log.isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span class="text-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
</div>
<div v-if="!log.isSuccess && log.errorMessage" class="mt-0.5">
<span class="text-[10px] text-red-500 leading-tight block">{{ log.errorMessage }}</span>
</div>
</div>
<button
@click="removeLog(log.id)"
class="shrink-0 ml-2 w-6 h-6 flex items-center justify-center rounded hover:bg-gray-200 text-gray-400 hover:text-red-500 transition-colors"
title="删除日志"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div v-else class="flex items-center justify-center py-6">
<p class="text-xs text-gray-400">暂无销假日志</p>
</div>
</div>
<!-- ======== 待销假列表弹窗 ======== -->
<Teleport to="body">
<div v-if="showPendingModal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closePendingModal"></div>
<div class="relative w-full sm:max-w-sm max-h-[85vh] bg-white rounded-t-2xl sm:rounded-2xl overflow-hidden flex flex-col">
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
<span class="text-sm font-semibold text-gray-900">选择请假记录</span>
<button @click="closePendingModal" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400">
<X class="w-4 h-4" />
</button>
</div>
<!-- 列表 -->
<div class="overflow-y-auto flex-1 p-4">
<div v-if="loadingLeaves" class="flex items-center justify-center py-12">
<LoaderCircle class="w-6 h-6 text-rose-500 animate-spin" />
</div>
<div v-else-if="pendingLeaves.length === 0" class="text-center py-12">
<CalendarCheck class="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p class="text-sm text-gray-400">暂无待销假记录</p>
<p class="text-xs text-gray-300 mt-1">有正在休假或待销假的请假单时会显示在这里</p>
</div>
<div v-else class="space-y-2">
<div
v-for="leave in pendingLeaves"
:key="leave.id"
@click="selectLeave(leave)"
class="relative p-3 rounded-lg border border-gray-100 hover:border-rose-300 hover:bg-rose-50 cursor-pointer transition-colors"
>
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ leave.reason || '无请假原因' }}</p>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
:style="{ backgroundColor: leaveStatusColor(leave.status) + '1a', color: leaveStatusColor(leave.status) }"
>{{ leaveStatusLabel(leave.status) }}</span>
</div>
<div class="text-[10px] text-gray-400 space-y-0.5">
<p>提交时间:{{ leave.createTime || '-' }}</p>
<p>预计结束:{{ leave.actEndTime || '-' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
<!-- ======== 销假表单弹窗 ======== -->
<Teleport to="body">
<div v-if="showFormModal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeFormModal"></div>
<div class="relative w-full sm:max-w-sm max-h-[85vh] bg-white rounded-t-2xl sm:rounded-2xl overflow-hidden flex flex-col">
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
<span class="text-sm font-semibold text-gray-900">确认销假信息</span>
<button @click="closeFormModal" :disabled="executing" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400 disabled:opacity-40">
<X class="w-4 h-4" />
</button>
</div>
<!-- 表单 -->
<div class="overflow-y-auto flex-1 p-4 space-y-3">
<!-- 请假信息 -->
<div v-if="selectedLeave" class="rounded-lg bg-gray-50 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
<span class="text-xs font-medium text-gray-900 truncate">{{ selectedLeave.reason || '无请假原因' }}</span>
</div>
<p class="text-[10px] text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
</div>
<!-- 规定销假位置选择(reportPlaces) -->
<div v-if="reportPlaces.length > 0">
<div class="flex items-start gap-1.5 rounded-lg bg-green-50 border border-green-100 p-2.5 mb-2">
<MapPin class="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<p class="text-[10px] text-green-600 leading-relaxed">
该请假要求在学校规定位置销假,请选择您当前所在的销假位置
</p>
</div>
<div class="space-y-1.5">
<div
v-for="(place, idx) in reportPlaces"
:key="place.wid || idx"
@click="selectPlace(place)"
class="p-2.5 rounded-lg border cursor-pointer transition-colors"
:class="selectedPlaceIndex === idx
? 'border-rose-400 bg-rose-50'
: 'border-gray-100 hover:border-rose-200'"
>
<div class="flex items-center gap-1.5">
<MapPin class="w-3.5 h-3.5 shrink-0" :class="selectedPlaceIndex === idx ? 'text-rose-500' : 'text-gray-300'" />
<span class="text-xs font-medium text-gray-900 flex-1 min-w-0 truncate">{{ place.address }}</span>
<span class="text-[10px] text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
</div>
<p class="text-[10px] text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
</div>
</div>
</div>
<div v-else class="flex items-start gap-1.5 rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<MapPin class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<p class="text-[10px] text-amber-600 leading-relaxed">
该请假未设置规定销假位置,请手动填写当前所在位置的经纬度与地址
</p>
</div>
<!-- 经纬度地址 -->
<div class="space-y-2.5">
<div>
<label class="text-xs text-gray-500 mb-1 block">经度</label>
<input
v-model="form.longitude"
type="text"
inputmode="decimal"
placeholder="请输入经度,如 118.123456"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:border-rose-400"
/>
</div>
<div>
<label class="text-xs text-gray-500 mb-1 block">纬度</label>
<input
v-model="form.latitude"
type="text"
inputmode="decimal"
placeholder="请输入纬度,如 24.654321"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:border-rose-400"
/>
</div>
<div>
<label class="text-xs text-gray-500 mb-1 block">地址</label>
<textarea
v-model="form.address"
rows="2"
placeholder="请输入销假地址"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:border-rose-400 resize-none"
></textarea>
</div>
</div>
<!-- 审批人选择(reportApplyFlow=true 时) -->
<div v-if="selectedLeave?.reportApplyFlow">
<label class="text-xs text-gray-500 mb-1 block">审批人 <span class="text-rose-500">*</span></label>
<div v-if="loadingApprovers" class="flex items-center justify-center py-3">
<LoaderCircle class="w-4 h-4 text-rose-400 animate-spin" />
</div>
<div v-else-if="approvers.length > 0" class="space-y-1.5">
<div
v-for="a in approvers"
:key="a.userWid"
@click="form.approverWid = a.userWid"
class="p-2.5 rounded-lg border cursor-pointer transition-colors flex items-center gap-2"
:class="form.approverWid === a.userWid
? 'border-rose-400 bg-rose-50'
: 'border-gray-100 hover:border-rose-200'"
>
<div
class="w-3.5 h-3.5 rounded-full border shrink-0 flex items-center justify-center"
:class="form.approverWid === a.userWid ? 'border-rose-500' : 'border-gray-300'"
>
<div v-if="form.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
</div>
</div>
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<p class="text-[10px] text-amber-600">未获取到可选的审批人,请稍后重试</p>
<button
@click="loadApprovers"
:disabled="loadingApprovers"
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
>重新获取</button>
</div>
</div>
<!-- 销假图片(仅 enableAttach=true 时显示,可选) -->
<div v-if="selectedLeave?.enableAttach">
<label class="text-xs text-gray-500 mb-1 block">销假图片(可选)</label>
<div v-if="form.imageUrl" class="relative inline-block">
<img :src="form.imageUrl" class="w-20 h-20 rounded-lg object-cover border border-gray-200" />
<button
@click="removeImage"
:disabled="executing"
class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-gray-900/70 text-white flex items-center justify-center"
>
<X class="w-3 h-3" />
</button>
</div>
<button
v-else
@click="handleImageUpload"
:disabled="uploadingImage || executing"
class="w-20 h-20 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-300 hover:border-rose-300 hover:text-rose-400 transition-colors"
>
<LoaderCircle v-if="uploadingImage" class="w-5 h-5 text-rose-400 animate-spin" />
<template v-else>
<Upload class="w-5 h-5" />
<span class="text-[10px] mt-1">上传图片</span>
</template>
</button>
</div>
<!-- 执行状态 -->
<p v-if="execMessage" class="text-[11px] leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
{{ execMessage }}
</p>
</div>
<!-- 底部按钮 -->
<div class="p-4 border-t border-gray-100 flex gap-2 shrink-0">
<button
@click="closeFormModal"
:disabled="executing"
class="flex-1 rounded-lg border border-gray-200 py-2.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>取消</button>
<button
@click="submitReport"
:disabled="executing"
class="flex-1 rounded-lg bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 py-2.5 text-sm font-medium text-white transition-colors flex items-center justify-center gap-1.5"
>
<LoaderCircle v-if="executing" class="w-4 h-4 animate-spin" />
{{ executing ? '销假中...' : '立即销假' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.float-slow {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { CalendarCheck, LoaderCircle, Trash2, X, MapPin, Upload } from 'lucide-vue-next'
import * as leaveApi from '@/api/leave'
import type { LeaveItem, LeaveReportPlace } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
const { message } = useMessage()
// ======== 销假日志 ========
const loadingLogs = ref(false)
const leaveLogs = ref<leaveApi.LeaveLog[]>([])
async function loadLogs() {
loadingLogs.value = true
try {
const res = await leaveApi.getLeaveLogs()
leaveLogs.value = res.data.data || []
} catch {
leaveLogs.value = []
} finally {
loadingLogs.value = false
}
}
</style>
async function removeLog(id: number) {
if (!confirm('确定要删除此条日志吗?')) return
try {
await leaveApi.deleteLeaveLog(id)
leaveLogs.value = leaveLogs.value.filter(l => l.id !== id)
} catch {
message.error('删除失败,请重试')
}
}
// ======== 待销假列表弹窗 ========
const showPendingModal = ref(false)
const loadingLeaves = ref(false)
const pendingLeaves = ref<LeaveItem[]>([])
function openPendingModal() {
showPendingModal.value = true
loadPendingLeaves()
}
function closePendingModal() {
showPendingModal.value = false
}
async function loadPendingLeaves() {
loadingLeaves.value = true
try {
const res = await leaveApi.getPendingLeaves()
pendingLeaves.value = res.data.data || []
} catch (e: any) {
pendingLeaves.value = []
message.error(e?.response?.data?.message || '获取待销假列表失败')
} finally {
loadingLeaves.value = false
}
}
// 请假状态映射(status → 展示文案 / 颜色)
const leaveStatusMap: Record<string, { label: string; color: string }> = {
'1': { label: '待审批', color: '#f0a020' },
'2': { label: '处理中', color: '#2080f0' },
'3': { label: '已驳回', color: '#d03050' },
'4': { label: '已完成', color: '#18a058' },
'5': { label: '即将休假', color: '#8a2be2' },
'6': { label: '正在休假中', color: '#36ad6a' },
'7': { label: '销假逾期', color: '#ff6b6b' },
'8': { label: '待销假', color: '#18a058' },
'9': { label: '自动通过', color: '#18a058' },
'10': { label: '逾期自动驳回', color: '#d03050' },
}
function leaveStatusLabel(status: string) {
return leaveStatusMap[status]?.label || '未知'
}
function leaveStatusColor(status: string) {
return leaveStatusMap[status]?.color || '#909399'
}
// ======== 销假表单弹窗 ========
const showFormModal = ref(false)
const selectedLeave = ref<LeaveItem | null>(null)
const form = ref({ longitude: '', latitude: '', address: '', imageUrl: '', approverWid: '' })
const executing = ref(false)
const execStatus = ref('') // connecting / error / success
const execMessage = ref('')
// 规定销假位置
const reportPlaces = ref<LeaveReportPlace[]>([])
const selectedPlaceIndex = ref(-1)
// 审批人(reportApplyFlow=true 时加载)
const approvers = ref<leaveApi.LeaveApprover[]>([])
const loadingApprovers = ref(false)
function selectLeave(leave: LeaveItem) {
selectedLeave.value = leave
reportPlaces.value = leave.reportPlaces || []
selectedPlaceIndex.value = -1
// 有规定销假位置:默认选中第一个;无:用户手动输入
if (reportPlaces.value.length > 0) {
selectPlace(reportPlaces.value[0])
} else {
form.value = { longitude: '', latitude: '', address: '', imageUrl: '', approverWid: '' }
}
approvers.value = []
execStatus.value = ''
execMessage.value = ''
showFormModal.value = true
if (leave.reportApplyFlow) {
loadApprovers()
}
}
function selectPlace(place: LeaveReportPlace) {
selectedPlaceIndex.value = reportPlaces.value.indexOf(place)
form.value.longitude = place.longitude || ''
form.value.latitude = place.latitude || ''
form.value.address = place.address || ''
}
async function loadApprovers() {
if (!selectedLeave.value) return
loadingApprovers.value = true
try {
const res = await leaveApi.getLeaveApprovers(selectedLeave.value.id)
approvers.value = res.data.data || []
// 只有一个审批人时默认选中
if (approvers.value.length === 1) {
form.value.approverWid = approvers.value[0].userWid
}
} catch (e: any) {
approvers.value = []
message.error(e?.response?.data?.message || '获取审批人失败')
} finally {
loadingApprovers.value = false
}
}
function closeFormModal() {
if (executing.value) return
showFormModal.value = false
if (abortExec) {
abortExec.abort()
abortExec = null
}
}
// ======== 销假图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),taskType=leave ========
const uploadingImage = ref(false)
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token') || ''
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
}
function handleImageUpload() {
if (uploadingImage.value) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png,image/jpeg,image/jpg'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
uploadingImage.value = true
try {
// Step 1: 从服务器获取 OBS 上传策略(仅获取凭证,不传文件)
const policyRes = await fetch('/api/task/upload-policy', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ taskType: 'leave' }),
})
const policyJson = await policyRes.json()
if (policyJson.code !== 0) {
message.error('获取上传策略失败: ' + (policyJson.message || policyRes.status))
return
}
const policy = policyJson.data
// Step 2: 浏览器直传到今日校园 OBS(按接口返回的 formData 字段集原样提交)
const obsForm = new FormData()
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, {
method: 'POST',
body: obsForm,
})
if (obsRes.status > 299) {
message.error('图片上传到 OBS 失败(' + obsRes.status + '),请重试')
return
}
// Step 3: 获取今日校园预览 URL
const previewRes = await fetch('/api/task/preview-url', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ taskType: 'leave', fileKey: policy.fileName || policy.FileName }),
})
const previewJson = await previewRes.json()
if (previewJson.code !== 0) {
message.error('获取预览 URL 失败: ' + (previewJson.message || previewRes.status))
return
}
form.value.imageUrl = previewJson.data.previewUrl
message.success('图片上传成功')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingImage.value = false
}
}
input.click()
}
function removeImage() {
form.value.imageUrl = ''
}
// ======== 执行销假(SSE 同步执行) ========
let abortExec: AbortController | null = null
// 组件卸载时中断进行中的请求
onUnmounted(() => {
if (abortExec) abortExec.abort()
})
async function submitReport() {
if (executing.value || !selectedLeave.value) return
const longitude = form.value.longitude.trim()
const latitude = form.value.latitude.trim()
if (!longitude || !latitude) {
message.warning('请填写销假经纬度')
return
}
if (Number.isNaN(Number(longitude)) || Number.isNaN(Number(latitude))) {
message.warning('经纬度格式不正确,请填写数字')
return
}
if (!form.value.address.trim() && reportPlaces.value.length === 0) {
message.warning('该请假未设置规定销假位置,请填写销假地址')
return
}
if (selectedLeave.value.reportApplyFlow && !form.value.approverWid) {
message.warning('该请假类型销假需要选择审批人')
return
}
executing.value = true
execStatus.value = 'connecting'
execMessage.value = '正在连接今日校园...'
abortExec = leaveApi.executeLeaveSSE(
{
leaveId: selectedLeave.value.id,
longitude,
latitude,
address: form.value.address.trim(),
imageUrl: form.value.imageUrl || undefined,
approverWid: form.value.approverWid || undefined,
},
{
onConnecting: () => {
execStatus.value = 'connecting'
execMessage.value = '正在连接今日校园...'
},
onFetchingDetail: () => {
execStatus.value = 'connecting'
execMessage.value = '正在获取请假详情...'
},
onSubmitting: () => {
execStatus.value = 'submitting'
execMessage.value = '正在提交销假...'
},
onSuccess: (data) => {
execStatus.value = 'success'
execMessage.value = data?.message || '销假成功!'
message.success(data?.message || '销假成功!')
loadLogs()
// 稍后自动关闭弹窗
setTimeout(() => {
showFormModal.value = false
showPendingModal.value = false
executing.value = false
execStatus.value = ''
execMessage.value = ''
abortExec = null
}, 1500)
},
onError: (msg, data) => {
// 限流提示
if (data?.rateLimit) {
message.warning(msg)
} else {
execStatus.value = 'error'
execMessage.value = msg
message.error(msg)
loadLogs()
}
executing.value = false
abortExec = null
},
},
)
}
onMounted(() => {
loadLogs()
})
</script>