7c2b9aa3a7
上一版 recordRequiresInArea 拉 50 条列表再线性查找留两条隐患:
记录超过 50 条时找不到 → 误判「不要求」→ 漏掉前端那道拦截。
核对前端源码后改为按 leaveId 精确查询(LeaveDetailView 的 ve()):
s.listType==="apply" && (d.leaveId = s.data.id, n = await Ft(d))
c.value = { ...n }
"cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
|| c.value?.cancellationApplyInArea === "是"
其中 Ft 即 studentForm 的 he → GET /leave/student_action/leave_detail。
改动:
- 新增 XsgzFetchLeaveDetail(GET leave_detail?leaveId=...,code!=0 报错);
- recordRequiresInArea 改为「详情优先、列表回退、都失败则保守 false」,
既消除分页隐患,也保留对详情接口异常的容错。
测试:详情接口路径与参数名、bool true / 字符串「是」两种标记解析、
非 0 code 必须报错(不能静默当成空详情)。
296 lines
11 KiB
Go
296 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
|
||
"cpdaily-new/internal/model"
|
||
cpd "cpdaily-new/internal/service/cpdaily"
|
||
"cpdaily-new/internal/utils"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
)
|
||
|
||
// xsgzTypesToLeaveTypes 把 xsgz 请假项目转换为平台通用的 LeaveType 契约。
|
||
//
|
||
// 该校请假类型只有 id/text,没有旧契约里的审批人/附件/字数等开关,
|
||
// 因此按最小可用集构造:不需要审批人、不允许附件、不限制字数。
|
||
func xsgzTypesToLeaveTypes(projs []cpd.XsgzLeaveType) []cpd.LeaveType {
|
||
out := make([]cpd.LeaveType, 0, len(projs))
|
||
for i, p := range projs {
|
||
out = append(out, cpd.LeaveType{
|
||
Code: i + 1,
|
||
ProjectID: p.ID, // 真实 UUID,提交时用它而非 code
|
||
Name: p.Text,
|
||
NeedApproval: 0,
|
||
EnableAttach: 0,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// newXsgzSession 用当前用户的辅导猫会话(CASTGC)换取 xsgz 学工系统的 access_token 会话。
|
||
//
|
||
// 链路:辅导猫登录 → CASTGC → CAS 换 service 指向 {xsgz}/oauth/callback 的票据
|
||
// → /api/cas/callback(form-urlencoded ticket=密文)→ access_token。
|
||
func newXsgzSession(ctx context.Context, user *model.SysUser) (*cpd.XsgzSession, error) {
|
||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||
if cookieMap["CASTGC"] == "" {
|
||
return nil, fmt.Errorf("登录凭证缺少 CASTGC,请重新登录")
|
||
}
|
||
service := cpd.XsgzCasServiceURL(user.SchoolID)
|
||
if service == "" {
|
||
return nil, fmt.Errorf("该校未配置学工系统地址")
|
||
}
|
||
|
||
// 用 CASTGC 换取指向学工系统的 CAS 票据
|
||
ticket, err := cpd.CasTicketForService(ctx, cpd.CasServiceConfig{
|
||
CasBase: getXsgzCasBase(user.SchoolID),
|
||
Service: service,
|
||
CASTGC: cookieMap["CASTGC"],
|
||
Username: user.StudentID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return cpd.XsgzLogin(ctx, user.SchoolID, ticket)
|
||
}
|
||
|
||
// getXsgzCasBase 返回该校 CAS 服务根地址(xsgz 学校与辅导猫共用同一 CAS)
|
||
func getXsgzCasBase(schoolID string) string {
|
||
switch schoolID {
|
||
case "sdau":
|
||
return "https://auths.sdau.edu.cn/authserver"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// rejectExternalLeaveSystem 拦截走学校独立学工系统的请假/销假请求。
|
||
//
|
||
// 背景:部分学校(如山东农业大学 xsgz、云南警官学院 xsfw)的请假由学校学工系统
|
||
// 独立提供,辅导猫侧请假应用为 HTTP 420「产品已到期」。这些学校的所有请假相关
|
||
// 入口(类型/审批人/待销假/执行/续假)都必须在此短路,否则会掉进辅导猫旧链路,
|
||
// 用户看到的只是误导性的 420 到期错误。
|
||
//
|
||
// 返回 true 表示已写出响应、调用方应立即 return。
|
||
func rejectExternalLeaveSystem(c *fiber.Ctx, user *model.SysUser, cookieMap map[string]string) bool {
|
||
switch schoolLeaveFlavor(user, cookieMap) {
|
||
case cpd.LeaveFlavorXsgz:
|
||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工系统"))
|
||
return true
|
||
case cpd.LeaveFlavorXsfw:
|
||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工平台"))
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// leaveExternalSystemHint 生成「该校请假走独立学工系统」的提示文案。
|
||
// 这类学校的辅导猫请假应用为 HTTP 420「产品已到期」,与我们无关;
|
||
// 直连辅导猫只会得到误导性的到期错误,故在此明确告知用户实际入口。
|
||
func leaveExternalSystemHint(schoolName, systemName string) string {
|
||
if schoolName == "" {
|
||
schoolName = "贵校"
|
||
}
|
||
return schoolName + " 的请假由学校「" + systemName + "」独立提供,不支持在今日校园内提交,请前往学校学工系统办理"
|
||
}
|
||
|
||
// 请假应用套件分流(新旧两套 API 并存):
|
||
//
|
||
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
|
||
// 旧套 wec-counselor-leave-apps(「请假」应用,默认)
|
||
//
|
||
// 分流依据:辅导猫 getAppInfos 返回的本校应用列表(openUrl 指向哪套);
|
||
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
|
||
|
||
type leaveFlavorCacheEntry struct {
|
||
flavor cpd.LeaveAppFlavor
|
||
expire time.Time
|
||
}
|
||
|
||
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
|
||
|
||
const leaveFlavorCacheTTL = 30 * time.Minute
|
||
|
||
// schoolLeaveFlavor 获取(并缓存)学校请假应用套件类型
|
||
func schoolLeaveFlavor(user *model.SysUser, cookieMap map[string]string) cpd.LeaveAppFlavor {
|
||
// 按学校 ID 单独适配的独立学工系统优先判断(不计入缓存:
|
||
// 判定依据是静态映射,无探测成本,也避免多套系统间缓存串味)
|
||
// xsgz:三易拓「新学工5」(如山东农业大学 xsgz.sdau.edu.cn)
|
||
// xsfw:金智 xsfw 学工平台(如云南警官学院 xg.ynpc.edu.cn/xsfwdt)
|
||
if cpd.IsXsgzSchool(user.SchoolID) {
|
||
return cpd.LeaveFlavorXsgz
|
||
}
|
||
if cpd.IsXsfwSchool(user.SchoolID) {
|
||
return cpd.LeaveFlavorXsfw
|
||
}
|
||
|
||
if v, ok := leaveFlavorCache.Load(user.SchoolID); ok {
|
||
if e, ok2 := v.(leaveFlavorCacheEntry); ok2 && time.Now().Before(e.expire) {
|
||
return e.flavor
|
||
}
|
||
}
|
||
|
||
host := getSchoolHost(user.SchoolID)
|
||
flavor, openURL := cpd.FetchLeaveAppFlavor(cookieMap, host)
|
||
if flavor == cpd.LeaveFlavorUnknown {
|
||
// getAppInfos 失败/无请假应用:试探新套端点兜底
|
||
if cpd.PingLeaveNew(cookieMap, host) {
|
||
flavor = cpd.LeaveFlavorNew
|
||
} else {
|
||
// 默认按旧套处理(cookie 过期等场景下旧套错误的语义更准确)
|
||
flavor = cpd.LeaveFlavorOld
|
||
}
|
||
}
|
||
slog.Info("请假应用套件分流", "schoolId", user.SchoolID, "flavor", flavor.String(), "openUrl", openURL)
|
||
leaveFlavorCache.Store(user.SchoolID, leaveFlavorCacheEntry{flavor: flavor, expire: time.Now().Add(leaveFlavorCacheTTL)})
|
||
return flavor
|
||
}
|
||
|
||
// invalidateLeaveFlavor 清缓存(应用迁移后可强制重探)
|
||
func invalidateLeaveFlavor(schoolID string) {
|
||
leaveFlavorCache.Delete(schoolID)
|
||
}
|
||
|
||
// ---------- 新套 → 现有前端契约的翻译 ----------
|
||
|
||
// leaveNewToLeaveTypes 新套类型列表 → 旧契约 LeaveType(前端零改动消费)
|
||
func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *cpd.LeaveNewFormDetail) []cpd.LeaveType {
|
||
out := make([]cpd.LeaveType, 0, len(list))
|
||
for _, t := range list {
|
||
item := cpd.LeaveType{
|
||
Code: t.TypeWid,
|
||
Name: t.TypeName,
|
||
}
|
||
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
|
||
if t.IsSign {
|
||
item.RequireSign = 1
|
||
}
|
||
if d := detailOf(t.TypeWid); d != nil {
|
||
if d.TypeName != "" {
|
||
item.Name = d.TypeName
|
||
}
|
||
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
|
||
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
|
||
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
|
||
item.OutStatusDefault = d.IsLeaveOut
|
||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||
item.NeedApproval = 1
|
||
if d.IsVisibledEvidenceFile {
|
||
item.EnableAttach = 1
|
||
item.AttachmentNumberLimit = d.EvidenceFileCountMax
|
||
} else {
|
||
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
|
||
item.AttachmentNumberLimit = 0
|
||
}
|
||
// 证明材料是否必填(isRequiredEvidenceFile):类型列表接口不返回该开关,
|
||
// 只在详情里;此前未透传,导致必填附件被漏检、提交被服务端拒绝。
|
||
if d.IsRequiredEvidence {
|
||
item.RequireEvidence = 1
|
||
}
|
||
item.EvidenceMin = d.EvidenceFileCountMini
|
||
// 自定义附加字段(selfDefineFields):必填项需前端采集、提交时回填 selfDefineAnswers
|
||
if raw, ok := d.Raw["selfDefineFields"]; ok && raw != nil {
|
||
if arr, ok := raw.([]interface{}); ok {
|
||
fields := make([]map[string]interface{}, 0, len(arr))
|
||
for _, it := range arr {
|
||
if m, ok := it.(map[string]interface{}); ok {
|
||
fields = append(fields, m)
|
||
}
|
||
}
|
||
item.SelfDefineFields = fields
|
||
}
|
||
}
|
||
item.ReasonWordLimit = d.LeaveReasonWordsMini
|
||
item.DisableTime = 0
|
||
item.LimitDay = 0
|
||
} else {
|
||
// 详情获取失败:保守默认(不阻塞列表)
|
||
item.NeedApproval = 1
|
||
item.EnableAttach = 1
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// leaveNewPlacesToLeaveReportPlaces 新套销假位置 → 旧契约 LeaveReportPlace
|
||
func leaveNewPlacesToLeaveReportPlaces(list []cpd.LeaveNewReportPlace) []cpd.LeaveReportPlace {
|
||
out := make([]cpd.LeaveReportPlace, 0, len(list))
|
||
for _, p := range list {
|
||
out = append(out, cpd.LeaveReportPlace{
|
||
Wid: p.Wid,
|
||
Address: p.Address,
|
||
Longitude: p.Longitude,
|
||
Latitude: p.Latitude,
|
||
Radius: p.Radius,
|
||
CounName: p.CounName,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// leaveNewApproversToLeaveApprovers 新套审批人 → 旧契约 LeaveApprover(前端展示用)
|
||
func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveApprover {
|
||
out := make([]cpd.LeaveApprover, 0, len(list))
|
||
for _, a := range list {
|
||
out = append(out, cpd.LeaveApprover{
|
||
UserWid: a.UserWid,
|
||
UserId: a.UserId,
|
||
UserName: a.UserName,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
|
||
//
|
||
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
|
||
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
|
||
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
|
||
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
|
||
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
|
||
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
|
||
//
|
||
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
|
||
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
|
||
func streamCtx() (context.Context, context.CancelFunc) {
|
||
return context.WithTimeout(context.Background(), 3*time.Minute)
|
||
}
|
||
|
||
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
|
||
//
|
||
// 前端从详情对象取该标记并作为 prop 传给提交弹窗:
|
||
//
|
||
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
|
||
// || c.value?.cancellationApplyInArea === "是"
|
||
//
|
||
// 其中 c.value 来自 leave_detail(listType==="apply")。
|
||
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按 leaveId 精确重查;
|
||
// 详情查不到时回退到列表查找;两者都失败则保守返回 false(不额外拦截,
|
||
// 交由学校端最终校验)。
|
||
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
|
||
if d, err := sess.XsgzFetchLeaveDetail(ctx, leaveID); err != nil {
|
||
slog.Warn("xsgz 查询请假详情失败,回退列表查找", "leaveId", leaveID, "error", err)
|
||
} else if d != nil {
|
||
return d.RequiresInArea()
|
||
}
|
||
|
||
recs, _, err := sess.XsgzFetchLeavePage(ctx, 1, 50)
|
||
if err != nil {
|
||
slog.Warn("xsgz 查询请假记录失败,跳过销假范围强制校验", "leaveId", leaveID, "error", err)
|
||
return false
|
||
}
|
||
for _, r := range recs {
|
||
if r.ID == leaveID {
|
||
return r.RequiresInArea()
|
||
}
|
||
}
|
||
return false
|
||
}
|