Files
cpdaily_xiaozhushou/backed/internal/handler/leave_apply.go
T
becomer 6bc7ab359c xsgz regions 字段不再被文本值污染,且必填时能正确拦下
前端 Ie() 对 regions 组件的处理(studentForm-Dx5Fpjfs.js):

  s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])
        ?.replace(/,/g,",")?.trim() || ""
  o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)

即 value 是「选中值,详情」的拼接串,详情还会把半角逗号换成全角。

本平台不提供地区选择,没有 _regionsDetail/_detail 可拼接,
此前的实现会把「请假原因」当作地区值写进 value,
学校端会收到无法解析的非法地区串。

改动:
- normalizeXsgzFields 对 regions 清空 value(置 "",由 xsgzFlatten 跳过,
  不会提交该键),避免残留脏值;
- fillXsgzFieldValues 跳过 regions(新增 xsgzNonTextFillable 统一判定
  上传类与地区类),不再把文本写进地区字段。

顺带修掉一个逻辑矛盾:checkXsgzUnfilledRequired 原先对「非文本可填」类型
一律 continue,导致**必填的 regions 字段永远不会被拦下**——
请求会带着空地区发出去,然后被学校端含糊拒绝。
现在只有上传类跳过(它由 uploadFiles 承载,无 value 属正常),
regions 必填无值时正常纳入预检,明确提示用户缺哪个字段。

测试:regions 不被填入请假原因、残留 value 被清空、
必填 regions 触发预检拦下(该用例正是暴露上述矛盾的地方)。
2026-10-17 18:24:19 +08:00

831 lines
32 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"
"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"
)
// ---------- 请假申请 ----------
// loadLeaveUser 读取当前用户(请假链路公共前置)
func loadLeaveUser(c *fiber.Ctx) (*model.SysUser, error) {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return nil, fmt.Errorf("用户不存在")
}
if user.Cookie == "" {
return nil, fmt.Errorf("请先登录")
}
return &user, nil
}
// GetLeaveTypes 获取请假类型列表(含全部权威开关,前端据此渲染表单)。
// 按学校应用的套件分流:新套(leave-new)翻译为同一契约返回。
func GetLeaveTypes(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 三易拓新学工5(如山东农业大学):请假类型来自学校学工系统
if schoolLeaveFlavor(user, cookieMap) == cpd.LeaveFlavorXsgz {
sess, sErr := newXsgzSession(c.Context(), user)
if sErr != nil {
slog.Error("建立学工系统会话失败", "userId", user.ID, "error", sErr)
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
}
projs, tErr := sess.XsgzFetchLeaveTypes(c.Context())
if tErr != nil {
slog.Error("获取学工系统请假类型失败", "userId", user.ID, "error", tErr)
return utils.Fail(c, 503, "获取请假类型失败: "+tErr.Error())
}
return utils.Success(c, xsgzTypesToLeaveTypes(projs))
}
// 其他独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
var types []cpd.LeaveType
if flavor == cpd.LeaveFlavorNew {
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
} else {
types, err = cpd.FetchLeaveTypes(cookieMap, host)
}
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
if flavor == cpd.LeaveFlavorNew {
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
} else {
types, err = cpd.FetchLeaveTypes(cookieMap, host)
}
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
}
return utils.Success(c, types)
}
// GetLeaveApplyApprovers 获取请假可选审批人(needApproval=1 的类型)。
// 请求:{ leaveType, startTime, endTime }(与销假审批人接口区分:此处选择类型发生在提交前)。
// 新套额外返回 flowData(提交请假时原样回传),前端不透明缓存即可。
func GetLeaveApplyApprovers(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
var req struct {
LeaveType int `json:"leaveType"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.StartTime == "" || req.EndTime == "" {
return utils.FailParam(c, "开始/结束时间不能为空")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:请假审批人不经辅导猫
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
if flavor == cpd.LeaveFlavorNew {
res, err := fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
}
// 保持与旧套一致的数组契约(flowData 由服务端在执行时重新获取,前端无需感知)
return utils.Success(c, res.Approvers)
}
approvers, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
}
return utils.Success(c, approvers)
}
// ExecuteLeaveApplyRequest 请假申请执行参数
type ExecuteLeaveApplyRequest struct {
LeaveType int `json:"leaveType"` // 类型编码
// XsgzProjectID 三易拓新学工5 的请假类型 ID(UUID)。
// 这类系统的类型 ID 不是整数 code,前端从 /leave/types 的 projectId 字段回传。
XsgzProjectID string `json:"projectId"`
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
LeaveReason string `json:"leaveReason"` // 请假原因
OutStatus bool `json:"outStatus"` // 是否离校
UrgencyMobile string `json:"urgencyMobile"` // 紧急联系人(离校必填)
Address string `json:"address"` // 目的地(离校必填)
DetailAddress string `json:"detailAddress"` // 详细地址(离校必填)
Longitude string `json:"longitude"` // 定位经度
Latitude string `json:"latitude"` // 定位纬度
LocationAddr string `json:"locationAddr"` // 定位地址
ImageURLs []string `json:"imageUrls"` // 附件图片 URL 列表(可选,最多4张且不超学校上限)
ImageURL string `json:"imageUrl"` // 兼容旧前端:单附件 URL(与 imageUrls 合并去重)
ApproverWid string `json:"approverWid"` // 审批人 userWid(候选为空时可不传)
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
// —— 续假(仅新套)——
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
// 自定义附加字段的作答(新套 selfDefineFields,如「当天是否返校」)
SelfDefineAnswers []map[string]interface{} `json:"selfDefineAnswers"`
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
func mergeApplyImageURLs(urls []string, legacy string) []string {
out := make([]string, 0, len(urls)+1)
seen := make(map[string]bool, len(urls)+1)
for _, u := range append(append([]string{}, urls...), legacy) {
u = strings.TrimSpace(u)
if u == "" || seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
// ExecuteLeaveApply 执行请假申请(SSE 同步执行:checkOnceLeave → 提交)。
// 事件流:connecting → submitting → success / task_error
func ExecuteLeaveApply(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req ExecuteLeaveApplyRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.StartTime = strings.TrimSpace(req.StartTime)
req.EndTime = strings.TrimSpace(req.EndTime)
req.LeaveReason = strings.TrimSpace(req.LeaveReason)
req.Longitude = strings.TrimSpace(req.Longitude)
req.Latitude = strings.TrimSpace(req.Latitude)
req.LocationAddr = strings.TrimSpace(req.LocationAddr)
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
// 附件:多图字段 + 旧前端兼容单图字段,去重合并
imageURLs := mergeApplyImageURLs(req.ImageURLs, req.ImageURL)
if len(imageURLs) > cpd.LeaveMaxAttachCount(0) {
return utils.FailParam(c, "附件最多 4 张")
}
if req.StartTime == "" || req.EndTime == "" {
return utils.FailParam(c, "开始/结束时间不能为空")
}
if req.LeaveReason == "" {
return utils.FailParam(c, "请假原因不能为空")
}
if req.Longitude == "" || req.Latitude == "" {
return utils.FailParam(c, "定位经纬度不能为空")
}
if req.LocationAddr == "" {
return utils.FailParam(c, "定位地址不能为空")
}
if req.OutStatus {
if strings.TrimSpace(req.UrgencyMobile) == "" {
return utils.FailParam(c, "紧急联系人手机号不能为空")
}
if strings.TrimSpace(req.Address) == "" {
return utils.FailParam(c, "目的地不能为空")
}
if strings.TrimSpace(req.DetailAddress) == "" {
return utils.FailParam(c, "详细地址不能为空")
}
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
}
// 三易拓新学工5(如山东农业大学):提交到学校学工系统
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
return executeXsgzLeaveApply(c, &user, &req, imageURLs)
}
// 其他独立学工系统:请假提交不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
applyAction := model.LeaveActionApply
if req.IsExtend {
applyAction = model.LeaveActionExtend
}
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
saveLog := func(isSuccess bool, errMsg string) {
logEntry := model.CpLeaveLog{
UserID: userID,
LeaveID: applyLeaveID,
Title: title,
SchoolName: user.SchoolName,
Action: applyAction,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
database.DB.Create(&logEntry)
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getLeaveDeduct()
if !isVip && user.SignCount < deduct {
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:与销假同规则(同一用户维度)
rateKey := fmt.Sprintf("leave-apply:%d", userID)
rules := []ratelimit.Rule{
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
}
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
slog.Warn("请假触发限流", "userId", userID, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 新套(leave-new):独立请假链路(类型详情复核 + 审批流 + 明文 JSON 提交)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
ok, errMsg, needReLogin, needApprover := leaveNewExecuteApply(&user, cookieMap, host, req, imageURLs)
if !ok {
slog.Error("请假申请失败(新套)", "userId", userID, "error", errMsg)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{
"status": "error",
"message": errMsg,
"needReLogin": needReLogin,
"needApprover": needApprover,
})
return
}
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "请假申请提交成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!(VIP 免扣次数)"})
}
slog.Info("请假申请成功(新套)", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
return
}
// 1. 获取类型配置(服务端复核开关,避免前端被绕过)
cfg, err := cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假类型配置失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
if cfg.DisableTime == 1 {
errMsg := "该请假类型当前不可用,请选择其他类型"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if cfg.ReasonWordLimit > 0 && len([]rune(req.LeaveReason)) < cfg.ReasonWordLimit {
errMsg := fmt.Sprintf("请假原因至少 %d 字", cfg.ReasonWordLimit)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 审批人:needApproval=1 且学校配置了候选审批人时必选;
// 候选列表为空(学校未配置审批节点人员,如 ysdwl「大四学生请假」)时允许不选,跳过即可
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
candidates, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil {
errMsg := "获取请假审批人失败: " + err.Error()
slog.Warn("校验审批人候选失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if len(candidates) > 0 {
errMsg := "该请假类型需要选择审批人"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
}
slog.Info("该类型无候选审批人,允许不选提交", "userId", userID, "leaveType", req.LeaveType)
}
// 附件复核:类型开关 + 数量上限(学校配置为准,产品上限 4 张)
// 注意:实测多所旧套学校 enableAttach 恒为 0(该字段在旧套不代表"可传附件"),
// 是否允许以 attachmentNumberLimit>0 为准;仅当两者都表明不可用时才拒绝。
if len(imageURLs) > 0 {
if cfg.EnableAttach != 1 && cfg.AttachmentNumberLimit <= 0 {
errMsg := "该请假类型不支持上传附件"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if maxN := cpd.LeaveMaxAttachCount(cfg.AttachmentNumberLimit); len(imageURLs) > maxN {
errMsg := fmt.Sprintf("附件最多 %d 张", maxN)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
}
// 2. 可请假检查
if err := cpd.CheckOnceLeave(cookieMap, host); err != nil {
errMsg := "当前不可请假: " + err.Error()
slog.Warn("checkOnceLeave 拒绝", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 3. 构造表单并提交
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
p := cpd.LeaveApplyParams{
LeaveType: req.LeaveType,
StartTime: req.StartTime,
EndTime: req.EndTime,
LeaveReason: req.LeaveReason,
OutStatus: req.OutStatus,
UrgencyMobile: req.UrgencyMobile,
Address: req.Address,
DetailAddress: req.DetailAddress,
Longitude: req.Longitude,
Latitude: req.Latitude,
LocationAddr: req.LocationAddr,
ImageURLs: imageURLs,
ApproverWid: req.ApproverWid,
ApproverId: req.ApproverId,
ApproverName: req.ApproverName,
CcUsers: req.CcUsers,
}
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
submit := func(cm map[string]string) error {
if req.IsExtend {
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
err = submit(cookieMap)
if err != nil && isCookieExpired(err) {
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = submit(cookieMap)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "请假申请失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("请假申请失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "请假申请提交成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "请假申请提交成功!(VIP 免扣次数)",
})
}
slog.Info("请假申请成功", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
})
return nil
}
// leaveApplyLogTitle 请假日志标题:类型名由前端传入 code 时的兜底为「请假」
func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
title := fmt.Sprintf("请假申请(类型%d)", leaveType)
if startTime != "" && endTime != "" {
title = fmt.Sprintf("请假申请 %s ~ %s", startTime, endTime)
}
return title
}
// executeXsgzLeaveApply 三易拓新学工5(如山东农业大学)提交请假。
//
// 与辅导猫链路完全独立:用 CASTGC 换 xsgz 会话后调用
// POST /api/leave/student_action/application_submit(multipart/form-data)。
// 事件流与辅导猫保持一致(connecting → submitting → success/task_error)。
func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveApplyRequest, imageURLs []string) error {
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpLeaveLog{
UserID: user.ID,
LeaveID: "apply_" + strconv.FormatInt(time.Now().Unix(), 10),
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionApply,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
// 写回各字段的 value 后作为 fields 提交。
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
if ffErr != nil {
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
fillXsgzFieldValues(formFields, req)
// 按前端 Ie() 语义清洗:上传类去 value、多选数组 join(",")
normalizeXsgzFields(formFields)
// 提交前预检:学校表单可能包含本平台无法提供值的必填字段
// (如"是否离校"单选)。与其提交后得到含糊失败,不如明确指出。
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
msg := "该校请假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
",请前往学校学工系统办理"
slog.Warn("xsgz 表单存在未填必填项", "userId", user.ID, "missing", missing)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
payload := cpd.XsgzSubmitPayload{
ProjectID: req.XsgzProjectID,
RequestType: "LEAVE",
StartTime: req.StartTime,
EndTime: req.EndTime,
Address: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
Fields: formFields,
}
body, err := sess.XsgzSubmitLeave(ctx, payload)
if err != nil {
msg := "提交请假失败: " + err.Error()
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 解析返回:code=0 视为成功
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &result)
if result.Code != 0 {
msg := result.Message
if msg == "" {
msg = "提交请假失败"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
slog.Info("xsgz 请假申请成功", "userId", user.ID, "projectId", req.XsgzProjectID,
"start", req.StartTime, "end", req.EndTime)
})
return nil
}
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义。
//
// 依据前端 transformFields 的字段结构:
//
// form_fields_for_add 返回 { fieldId, fieldName, componentType, fieldType, required, options[] }
//
// 提交时前端遍历字段,把用户输入按 fieldId 写回 value。本平台只有「请假原因」
// 一项自由文本,因此:
// 1. 优先按标签(fieldName 含"原因/事由/说明")精确匹配;
// 2. 未命中时,退化为「必填的文本类字段」(fieldType 为 TEXT/TEXTAREA/LONGTEXT)
// 一律填入请假原因——该校表单通常只有一个原因字段,这样可以避免因
// 学校自定义字段名(如"请假事由说明""申请理由")导致漏填。
//
// 选择类字段(RADIO/CHECKBOX/SELECT 等)不猜测,留空由学校端校验。
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
if len(fields) == 0 || req.LeaveReason == "" {
return
}
textTypes := map[string]bool{"TEXT": true, "TEXTAREA": true, "LONGTEXT": true}
// 第一轮:按标签匹配
matched := false
for _, f := range fields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue // 上传类/地区类字段不该被文本值覆盖
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if strings.Contains(label, "原因") || strings.Contains(label, "事由") || strings.Contains(label, "说明") {
f["value"] = req.LeaveReason
matched = true
}
}
if matched {
return
}
// 第二轮:必填文本字段兜底
for _, f := range fields {
ft := strings.ToUpper(mapStr(f, "fieldType"))
required, _ := f["required"].(bool)
if required && textTypes[ft] {
f["value"] = req.LeaveReason
}
}
}
// mapStr 安全取 map 中的字符串值
func mapStr(m map[string]interface{}, k string) string {
if v, ok := m[k]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}
// checkXsgzUnfilledRequired 返回仍未填值的必填字段名称列表。
//
// 学校表单由后台配置,可能包含本平台无法提供值的必填字段
// (如"是否离校"单选、"请假类型细分"下拉)。与其让提交到学校端后
// 得到含糊的失败,不如在提交前明确告知缺哪些字段。
func checkXsgzUnfilledRequired(fields []map[string]interface{}) []string {
var missing []string
for _, f := range fields {
required, _ := f["required"].(bool)
if !required {
continue
}
// 上传类字段由 uploadFiles 承载,没有 value 属正常,
// 不应算作「未填写」(本平台不代传附件,由学校端按需处理)。
// 注意:地区类(regions)不能在此跳过——它就是要靠 value 承载,
// 我们没有数据就必然提交空值,必须拦下并告知用户。
if xsgzUploadComponentTypes[mapStr(f, "componentType")] {
continue
}
if v, ok := f["value"]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
continue
}
name := mapStr(f, "fieldName")
if name == "" {
name = mapStr(f, "fieldId")
}
if name == "" {
name = "未命名字段"
}
missing = append(missing, name)
}
return missing
}
// xsgzUploadComponentTypes 前端 Ie() 里走 uploadFiles 分支的组件类型。
var xsgzUploadComponentTypes = map[string]bool{
"imageUpload": true,
"fileUpload": true,
"smoothSignature": true,
}
// xsgzRegionComponentType 地区选择组件。
//
// 前端 Ie() 对它的处理是把「选中值」与「详情」拼接后写入 value:
//
// s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])?.replace(/,/g,",")?.trim() || ""
// o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)
//
// 本平台不提供地区选择,也就没有 _regionsDetail/_detail 可拼接,
// 因此不能把「请假原因」当成地区值写进去——那会产生学校端无法解析的
// 非法地区串。这类字段一律留空,由学校端校验或用户在学工系统内补填。
const xsgzRegionComponentType = "regions"
// xsgzNonTextFillable 判断字段是否不应被文本值(如请假原因)填充。
func xsgzNonTextFillable(componentType string) bool {
return xsgzUploadComponentTypes[componentType] || componentType == xsgzRegionComponentType
}
// normalizeXsgzFields 按前端 Ie()(studentForm-Dx5Fpjfs.js)的语义净化字段定义。
//
// 前端对每个字段按 componentType 分三支处理,直接把表单值映射到提交对象:
//
// regions: value = 拼接后的地名(含 _regionsDetail / _detail 后缀处理)
// imageUpload / fileUpload / smoothSignature:
// o.uploadFiles = 文件列表(并 delete content/url),**不设 value**
// 其余: o.value = Array.isArray(n) ? n.join(",") : n
//
// 我们的实现只填了 value,存在两个问题:
// 1. 上传类字段被塞了 value(前端不会这样提交),且完全没有 uploadFiles;
// 2. 多选类字段若是数组,前端会 join(","),我们直接保留数组,
// 经 xsgzFlatten 展开成 fields[n].value[k] 这种学校端读不到的键。
//
// 本函数只做「清洗」:去掉不该出现的内容,不臆造用户未提供的数据。
func normalizeXsgzFields(fields []map[string]interface{}) {
for _, f := range fields {
ct := mapStr(f, "componentType")
if xsgzUploadComponentTypes[ct] {
// 上传类:value 不应存在(由 uploadFiles 承载)。
// 本平台不代传附件,保持为空即可。
delete(f, "value")
continue
}
if ct == xsgzRegionComponentType {
// 地区类:前端 value 是「选中值,详情」的拼接串。
// 我们既无选中值也无 _regionsDetail/_detail,若残留 value
// 会被学校端当作非法地区解析,故清空但保留键(空值由
// xsgzFlatten 跳过,不会提交)。
f["value"] = ""
continue
}
// 其余:若 value 是数组,按前端规则 join(",")(多选拼接)
if arr, ok := f["value"].([]interface{}); ok {
parts := make([]string, 0, len(arr))
for _, v := range arr {
if v == nil {
continue
}
s := fmt.Sprintf("%v", v)
if s != "" {
parts = append(parts, s)
}
}
f["value"] = strings.Join(parts, ",")
}
}
}