Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7639912d7c | |||
| 2f20eebccd | |||
| 3aee45be80 | |||
| 46c8a554ff |
@@ -88,6 +88,18 @@ 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" // 销假
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`
|
||||
@@ -26,6 +26,7 @@ type TaskType string
|
||||
const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const avatarFailed = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '销假', icon: CalendarX2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user