Files
cpdaily_xiaozhushou/backed/internal/handler/leave.go
T
becomer e38fd89425 修销假两个线上报错:projectId 为空、CAS 参数解码错误
线上(162,v1.2.160,userId=1490)实测两类报错:
  提交销假失败: 缺少请假类型(projectId)
  学工系统登录失败: 登录失败: 参数解码错误,刷新页面后尝试
且伴随 WARN「获取销假表单字段失败: 未找到数据」——因为 projectId 为空,
form_fields_for_add 查不到任何东西。

1) projectId 为空
   projectId 是销假提交必传项,但前端销假请求体未必带(TypeWid 在列表里,
   不一定回传到执行接口)。原实现直接拿 req.ProjectID 用,为空即报错。
   修复:
   - 为空时按 leaveId 调 leave_detail 回查补齐,补到后记 INFO;
   - 回查仍拿不到则明确报「无法确定该请假记录所属的请假类型,
     请返回列表刷新后重试」,而不是发出注定失败的提交。
   - 同时修 XsgzFetchLeaveDetail 的解析:前端取的是
     `c.value?.project?.id || c.value?.projectId`,项目 id 可能嵌在
     project 对象里;原先只读顶层 projectId,现按「顶层优先、
     回落 project.id」处理,项目名同样回落到 project.text。

2) CAS 参数解码错误
   前端 getOAuthToken 的 body 是 `ticket=${s}` 直接拼接,**不做 URL 编码**。
   我们原先用 url.QueryEscape,而密文由多块小写 hex 以**空格**连接
   (实测长 ticket 分 2 块),QueryEscape 会把空格变成 '+';
   服务端按原始 body 解析时还原不回空格,逐块解密失败,
   正好对应「参数解码错误,刷新页面后尝试」。
   修复:改为与前端一致的直接拼接。

测试:
- 回查补齐 projectId 的两条路径(嵌套 project.id、顶层优先);
- CAS body 保留原始空格、不得出现 '+';
- 反例固化 QueryEscape 会破坏 ticket,说明为何不能用它。
2026-10-17 23:05:30 +08:00

645 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// ---------- 销假(请假销假)----------
// GetPendingLeaves 获取今日校园待销假列表(实时请求今日校园,附带规定销假位置)
func GetPendingLeaves(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 三易拓新学工5(如山东农业大学):走学校学工系统的请假记录
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorXsgz {
sess, sErr := newXsgzSession(c.Context(), &user)
if sErr != nil {
slog.Error("建立学工系统会话失败", "userId", userID, "error", sErr)
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
}
// 说明:学校的 /api/leave/record/batch-cancel-list 是**辅导员**接口
// (学生调用返回「当前用户没有辅导员销假权限」),因此学生端改用
// leave_page 拉全部记录,再由 ToLeaveItems 按状态标记 CanReport。
recs, _, pErr := sess.XsgzFetchLeavePage(c.Context(), 1, 50)
if pErr != nil {
slog.Error("获取学工系统请假记录失败", "userId", userID, "error", pErr)
return utils.Fail(c, 503, "获取请假记录失败: "+pErr.Error())
}
return utils.Success(c, sess.ToLeaveItems(recs))
}
// 其他独立学工系统:销假不经辅导猫,给出明确提示
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 应用套件分流:新套(leave-new)走独立链路
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
var items []cpd.LeaveItem
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
if isNewFlavor {
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
var e error
items, e = fetchLeaveNewPendingList(cm, host, &user)
return e
}
}
var e error
items, e = cpd.FetchLeaveList(cm, host)
return e
})
if err != nil {
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
if isCpdailyAuthError(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
}
return utils.Success(c, items)
}
// ExecuteLeaveRequest 销假执行参数
type ExecuteLeaveRequest struct {
LeaveID string `json:"leaveId"` // 今日校园请假记录 ID
Longitude string `json:"longitude"` // 销假经度(必填)
Latitude string `json:"latitude"` // 销假纬度(必填)
Address string `json:"address"` // 销假地址(无规定位置时必填)
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
// ProjectID 销假所属请假类型(三易拓新学工5 必传 UUID)
ProjectID string `json:"projectId"`
}
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
// 事件流:connecting → fetching_detail → submitting → success / task_error
func ExecuteLeave(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req ExecuteLeaveRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.LeaveID = strings.TrimSpace(req.LeaveID)
req.Longitude = strings.TrimSpace(req.Longitude)
req.Latitude = strings.TrimSpace(req.Latitude)
req.Address = strings.TrimSpace(req.Address)
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
if req.LeaveID == "" || req.Longitude == "" || req.Latitude == "" {
return utils.FailParam(c, "销假经纬度不能为空")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
}
// 三易拓新学工5(如山东农业大学):销假提交到学校学工系统
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
return executeXsgzLeaveCancel(c, &user, &req)
}
// 其他独立学工系统:销假不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
// SSE 写入器:所有检查在流内进行,通过 SSE event 返回结果
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
// 保存销假日志
saveLog := func(isSuccess bool, errMsg, title string) {
logEntry := model.CpLeaveLog{
UserID: userID,
LeaveID: req.LeaveID,
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionReport,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
database.DB.Create(&logEntry)
}
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getReportDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:同一用户(销假为同步执行,无任务维度,按用户维度限流)
rateKey := fmt.Sprintf("leave:%d", userID)
rules := []ratelimit.Rule{
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
}
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
slog.Warn("销假触发限流", "userId", userID, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
// 1. 连接成功
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 新套(leave-new):独立销假链路(详情校验 → 销假提交)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
title := "销假"
if d, derr := cpd.FetchLeaveNewDetail(cookieMap, host, req.LeaveID); derr == nil {
title = leaveLogTitle(&cpd.LeaveDetail{LeaveType: d.TopicName}, req.LeaveID)
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
ok, errMsg, needReLogin := leaveNewExecuteReport(&user, cookieMap, host,
req.LeaveID, req.Address, req.Longitude, req.Latitude, req.ImageURL, req.ApproverWid)
if !ok {
slog.Error("销假失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", errMsg)
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLogin})
return
}
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "销假成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
}
slog.Info("销假成功(新套)", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
return
}
// 2. 获取请假详情
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
slog.Info("获取请假详情cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假详情失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, leaveLogTitle(detail, ""))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
title := leaveLogTitle(detail, req.LeaveID)
// 销假地址:用户选择/填写的优先,否则兜底第一个规定销假位置地址;两者皆空时阻止提交
if req.Address == "" && len(detail.ReportPlaces) == 0 {
errMsg := "该请假未设置规定销假位置,请填写销假地址"
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 审批人:请假类型要求销假审批(reportApplyFlow=1)时必须选择审批人
if detail.ReportApplyFlow && req.ApproverWid == "" {
errMsg := "该请假类型销假需要选择审批人,请重新选择"
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
}
// 3. 构造表单并提交销假
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
form := cpd.BuildLeaveReportForm(detail, req.Address, req.ImageURL, req.ApproverWid)
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交销假cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "销假失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "销假成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "销假成功!(VIP 免扣次数)",
})
}
slog.Info("销假成功", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
})
return nil
}
// GetLeaveApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型)。
// 请求:{ leaveId };服务端自行取详情中的 leaveType/起止时间,前端无需重复传递。
func GetLeaveApprovers(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req struct {
LeaveID string `json:"leaveId"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.LeaveID = strings.TrimSpace(req.LeaveID)
if req.LeaveID == "" {
return utils.FailParam(c, "leaveId 不能为空")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:销假审批人也不经辅导猫
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 新套:销假确认人(getReportApprovers by leaveId)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
}
return utils.Success(c, approvers)
}
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
}
if !detail.ReportApplyFlow {
return utils.Success(c, []cpd.LeaveApprover{})
}
approvers, err := cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
}
return utils.Success(c, approvers)
}
// leaveLogTitle 销假日志标题:请假类型名 + "销假";类型名缺失时兜底用 leaveId 前缀避免标题为空
func leaveLogTitle(detail *cpd.LeaveDetail, leaveID string) string {
if detail != nil && detail.LeaveType != "" {
return detail.LeaveType + "销假"
}
if leaveID != "" {
return "销假(" + leaveID + ")"
}
return "销假"
}
// ListLeaveLogs 获取销假日志列表
func ListLeaveLogs(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var logs []model.CpLeaveLog
database.DB.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(10).
Find(&logs)
return utils.Success(c, logs)
}
// DeleteLeaveLog 删除销假日志
func DeleteLeaveLog(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var log model.CpLeaveLog
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&log).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "日志不存在")
}
return utils.FailServer(c, "查询失败")
}
database.DB.Delete(&log)
return utils.Success(c, nil)
}
// executeXsgzLeaveCancel 三易拓新学工5(如山东农业大学)提交销假。
//
// 契约源自前端 ActionPopup.vue:
//
// POST /api/leave/student_action/application_submit (multipart)
// { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
// fields[], cancellationApplyInArea, inArea, address, longitude, latitude }
//
// 即销假与请假共用同一提交接口,靠 requestType 区分。
func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveRequest) error {
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpLeaveLog{
UserID: user.ID,
LeaveID: req.LeaveID,
Title: "销假申请",
SchoolName: user.SchoolName,
Action: model.LeaveActionReport,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
// 直接导致「缺少请假类型(projectId)」)。
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
if req.ProjectID == "" {
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
slog.Warn("xsgz 回查销假详情补 projectId 失败",
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
} else if d != nil && d.ProjectID != "" {
req.ProjectID = d.ProjectID
slog.Info("xsgz 已回查补齐 projectId",
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
}
}
if req.ProjectID == "" {
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
if ffErr != nil {
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段。
// 注意:上传类与地区类字段不能被文本值填充(地区类需要
// 「选中值,详情」拼接串,写地址会被学校端当成非法地区),
// 这两类在下面的 normalizeXsgzFields 里另有处理。
for _, f := range formFields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
f["value"] = req.Address
}
}
// 按前端 Ie() 语义清洗:上传类去 value、地区类清空、多选数组 join(",")
normalizeXsgzFields(formFields)
// 提交前预检:必填项无法自动填写时明确指出,避免含糊失败
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
msg := "该校销假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
",请前往学校学工系统办理"
slog.Warn("xsgz 销假表单存在未填必填项", "userId", user.ID, "missing", missing)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
// v = actionType==="CANCELAPPLY" && cancellationApplyInArea
// if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
// 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
// 但仍把真实计算结果放进 inArea 上报给学校端。
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
} else if len(ranges) > 0 {
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
if lngErr != nil || latErr != nil {
slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
} else {
inArea = cpd.InLocationRange(ranges, lng, lat)
}
}
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
if requiresInArea && !inArea {
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID,
OriginalID: req.LeaveID,
Address: req.Address,
Longitude: req.Longitude,
Latitude: req.Latitude,
InArea: inArea,
Fields: formFields,
})
if err != nil {
msg := "提交销假失败: " + err.Error()
slog.Error("xsgz 提交销假失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &result)
if result.Code != 0 {
msg := result.Message
if msg == "" {
msg = "提交销假失败"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假申请提交成功!"})
slog.Info("xsgz 销假申请成功", "userId", user.ID, "leaveId", req.LeaveID)
})
return nil
}