6bc7ab359c
前端 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 触发预检拦下(该用例正是暴露上述矛盾的地方)。
831 lines
32 KiB
Go
831 lines
32 KiB
Go
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, ",")
|
||
}
|
||
}
|
||
}
|