Files
cpdaily_xiaozhushou/backed/internal/handler/leave_flavor.go
T
becomer 7c2b9aa3a7 销假范围标记改用 leave_detail 精确查询,避免分页上限导致漏拦截
上一版 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 必须报错(不能静默当成空详情)。
2026-10-17 18:20:28 +08:00

296 lines
11 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 (
"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
}