c47fb0a66e
ci/woodpecker/tag/build Pipeline was successful
手机号登录(无密码)账号 cookie 过期后自动续期失败时,错误链 只带普通错误、不带 needReLogin 信号,导致前端只显示普通错误、 不弹「重新登录」引导,用户无法跳转登录页。 - 新增 ErrNeedReLogin 哨兵 + needReLoginSignal() 统一判定: 密码错误或无法自动续期都视为需要重新登录 - refreshCpdailyCookie 无密码/MFA 无法自动续期时返回 ErrNeedReLogin - 所有执行路径(签到/请假/销假/返校/工作日志/表单/上传/预览) 的续期失败分支统一升级为 wrapNeedReLogin,SSE task_error 与 HTTP 401 均按 needReLoginSignal 判定
312 lines
11 KiB
Go
312 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
|
||
"cpdaily-new/internal/model"
|
||
cpd "cpdaily-new/internal/service/cpdaily"
|
||
)
|
||
|
||
// ---------- 新套执行链路(请假/销假) ----------
|
||
|
||
// leaveNewResolveApproverAndFlow 服务端自取审批流:按 userWid 匹配完整审批人对象 + flowData。
|
||
// 返回 (approver, flowData, candidates, err):
|
||
// - approver 为 nil 表示未提供 userWid 或未匹配(调用方决定是否放行)
|
||
// - candidates 为全部候选(用于「无候选可放行」判定)
|
||
func leaveNewResolveApproverAndFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, approverWid string) (*cpd.LeaveNewApprover, map[string]interface{}, []cpd.LeaveNewApprover, error) {
|
||
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
if approverWid == "" {
|
||
return nil, flow.FlowData, flow.ApproveUsers, nil
|
||
}
|
||
for i := range flow.ApproveUsers {
|
||
if flow.ApproveUsers[i].UserWid == approverWid {
|
||
return &flow.ApproveUsers[i], flow.FlowData, flow.ApproveUsers, nil
|
||
}
|
||
}
|
||
return nil, flow.FlowData, flow.ApproveUsers, nil
|
||
}
|
||
|
||
// leaveNewResolveCcUsers 抄送人 userWid 列表 → 完整对象(新套 getCcSearchList 全量匹配)
|
||
func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []string) []cpd.LeaveNewApprover {
|
||
if len(widList) == 0 {
|
||
return nil
|
||
}
|
||
all, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, "")
|
||
if err != nil {
|
||
slog.Warn("获取抄送人候选失败,抄送人将被忽略", "error", err)
|
||
return nil
|
||
}
|
||
want := make(map[string]bool, len(widList))
|
||
for _, w := range widList {
|
||
if w != "" {
|
||
want[w] = true
|
||
}
|
||
}
|
||
out := make([]cpd.LeaveNewApprover, 0, len(widList))
|
||
seen := make(map[string]bool, len(widList))
|
||
for _, a := range all {
|
||
if want[a.UserWid] && !seen[a.UserWid] {
|
||
seen[a.UserWid] = true
|
||
out = append(out, a)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
|
||
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
|
||
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
|
||
if err != nil {
|
||
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
|
||
return false
|
||
}
|
||
for _, t := range types {
|
||
if t.TypeWid == typeWid {
|
||
return t.IsSign
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
|
||
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
|
||
func leaveNewOutStatusValue(outStatus bool) int {
|
||
if outStatus {
|
||
return 1
|
||
}
|
||
return 2
|
||
}
|
||
|
||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||
func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyRequest, imageURLs []string) string {
|
||
if d == nil {
|
||
return ""
|
||
}
|
||
if d.IsRequiredLeaveReason && strings.TrimSpace(req.LeaveReason) == "" {
|
||
return "请填写请假原因"
|
||
}
|
||
if d.LeaveReasonWordsMini > 0 && len([]rune(req.LeaveReason)) < d.LeaveReasonWordsMini {
|
||
return fmt.Sprintf("请假原因不能少于 %d 字", d.LeaveReasonWordsMini)
|
||
}
|
||
// 证明材料(附件):多图复核 —— 可见性开关 / 必填 / 张数下限 / 学校上限(产品上限 4)
|
||
if len(imageURLs) > 0 {
|
||
if !d.IsVisibledEvidenceFile {
|
||
return "该请假类型不支持上传附件"
|
||
}
|
||
if maxN := cpd.LeaveMaxAttachCount(d.EvidenceFileCountMax); len(imageURLs) > maxN {
|
||
return fmt.Sprintf("附件最多 %d 张", maxN)
|
||
}
|
||
}
|
||
if d.IsRequiredEvidence {
|
||
if len(imageURLs) == 0 {
|
||
return "请上传证明材料"
|
||
}
|
||
if d.EvidenceFileCountMini > 0 && len(imageURLs) < d.EvidenceFileCountMini {
|
||
return fmt.Sprintf("证明材料至少 %d 张", d.EvidenceFileCountMini)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ---------- 新套销假执行 ----------
|
||
|
||
// leaveNewExecuteReport 新套销假执行(供 handler SSE 调用)。
|
||
// 返回 (是否成功, 用户可见错误信息, 需要重新登录)
|
||
func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, host string,
|
||
leaveID, address, longitude, latitude, imageURL, approverWid string) (bool, string, bool) {
|
||
|
||
// 1. 详情 + 状态复核(是否处于可销假状态)
|
||
detail, err := cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||
if err != nil {
|
||
if isCookieExpired(err) {
|
||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||
} else {
|
||
return false, "登录凭证已过期,请重新登录", true
|
||
}
|
||
}
|
||
if err != nil {
|
||
return false, "获取请假详情失败: " + err.Error(), false
|
||
}
|
||
}
|
||
if !detail.CanReport {
|
||
return false, fmt.Sprintf("当前请假单不可销假(状态:%s)", detail.StatusText), false
|
||
}
|
||
|
||
// 2. 确认人(reportMode=2 老师审批销假时需要)
|
||
var approver *cpd.LeaveNewApprover
|
||
if detail.ReportMode == "2" && approverWid != "" {
|
||
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
|
||
if err == nil {
|
||
for i := range list {
|
||
if list[i].UserWid == approverWid {
|
||
approver = &list[i]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 构造并提交
|
||
form := cpd.BuildLeaveNewReportForm(cpd.LeaveNewReportParams{
|
||
LeaveID: leaveID,
|
||
Address: address,
|
||
Longitude: longitude,
|
||
Latitude: latitude,
|
||
ImageURL: imageURL,
|
||
Approver: approver,
|
||
UserID: user.StudentID,
|
||
DeviceID: user.MobileCode,
|
||
Model: "cpdaily",
|
||
})
|
||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||
if err != nil && isCookieExpired(err) {
|
||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||
} else {
|
||
return false, "登录凭证已过期,请重新登录", true
|
||
}
|
||
}
|
||
if err != nil {
|
||
return false, "销假失败: " + err.Error(), false
|
||
}
|
||
return true, "", false
|
||
}
|
||
|
||
// ---------- 新套请假执行 ----------
|
||
|
||
// leaveNewExecuteApply 新套请假执行(供 handler SSE 调用)。
|
||
// 返回 (是否成功, 用户可见错误信息, 需要重新登录, 需要选择审批人)
|
||
func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host string, req ExecuteLeaveApplyRequest, imageURLs []string) (bool, string, bool, bool) {
|
||
// 1. 类型详情复核
|
||
detail, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||
if err != nil {
|
||
if isCookieExpired(err) {
|
||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||
} else {
|
||
return false, "登录凭证已过期,请重新登录", true, false
|
||
}
|
||
}
|
||
if err != nil {
|
||
return false, "获取请假类型配置失败: " + err.Error(), false, false
|
||
}
|
||
}
|
||
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
|
||
return false, msg, false, false
|
||
}
|
||
// 手写承诺书:isSign=1 的类型必须携带签名图。
|
||
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
|
||
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
|
||
return false, "请先手写承诺书签名", false, false
|
||
}
|
||
|
||
// 2. 审批流:取 flowData + 匹配完整审批人
|
||
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||
if err != nil {
|
||
if isCookieExpired(err) {
|
||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||
} else {
|
||
return false, "登录凭证已过期,请重新登录", true, false
|
||
}
|
||
}
|
||
if err != nil {
|
||
return false, "获取请假审批人失败: " + err.Error(), false, false
|
||
}
|
||
}
|
||
if req.ApproverWid == "" && len(candidates) > 0 {
|
||
return false, "该请假类型需要选择审批人", false, true
|
||
}
|
||
|
||
// 3. 抄送人解析(非必填)
|
||
ccWids := make([]string, 0, len(req.CcUsers))
|
||
for _, cc := range req.CcUsers {
|
||
ccWids = append(ccWids, cc.UserWid)
|
||
}
|
||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||
|
||
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
|
||
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
|
||
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
|
||
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
|
||
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
|
||
|
||
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
|
||
var extendID string
|
||
if req.IsExtend {
|
||
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||
if rerr != nil && isCookieExpired(rerr) {
|
||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||
} else {
|
||
return false, "登录凭证已过期,请重新登录", true, false
|
||
}
|
||
}
|
||
if rerr != nil {
|
||
return false, "获取续假信息失败: " + rerr.Error(), false, false
|
||
}
|
||
extendID = rd.ExtendID
|
||
}
|
||
|
||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||
TypeWid: req.LeaveType,
|
||
LeaveID: req.ExtendLeaveID,
|
||
IsExtend: req.IsExtend,
|
||
ExtendID: extendID,
|
||
ReApply: req.IsExtend,
|
||
BeginTime: trimSeconds(req.StartTime),
|
||
EndTime: trimSeconds(req.EndTime),
|
||
LeaveReason: req.LeaveReason,
|
||
ImageURLs: imageURLs,
|
||
SignatureURL: req.SignatureURL,
|
||
LocationAddr: req.LocationAddr,
|
||
Longitude: req.Longitude,
|
||
Latitude: req.Latitude,
|
||
IsLeaveOut: isLeaveOut,
|
||
Destination: req.Address,
|
||
Detail: req.DetailAddress,
|
||
EmergencyName: req.UrgencyMobile,
|
||
EmergencyMobile: req.UrgencyMobile,
|
||
Approver: approver,
|
||
CcUsers: ccUsers,
|
||
FlowData: flowData,
|
||
UserID: user.StudentID,
|
||
DeviceID: user.MobileCode,
|
||
Model: "cpdaily",
|
||
SelfDefineAnswers: req.SelfDefineAnswers,
|
||
})
|
||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||
if err != nil && isCookieExpired(err) {
|
||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||
} else {
|
||
return false, "登录凭证已过期,请重新登录", true, false
|
||
}
|
||
}
|
||
if err != nil {
|
||
return false, "请假申请失败: " + err.Error(), false, false
|
||
}
|
||
return true, "", false, false
|
||
}
|
||
|
||
// trimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)
|
||
func trimSeconds(t string) string {
|
||
t = strings.TrimSpace(t)
|
||
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
|
||
return t[:16]
|
||
}
|
||
return t
|
||
}
|