销假范围强制校验对齐前端:读 cancellationApplyInArea 决定是否拦截

上一版(355557b)算出 inArea 就上报,但没有判断「该校是否要求在范围内」,
等于把前端的一道拦截丢了。前端源码(LeaveDetailView-BveoXCrD.js):

  const v = computed(() => a.actionType==="CANCELAPPLY" && a.cancellationApplyInArea)
  if (v.value && !T?.canPosition) { warning("不在销假申请范围内,无法销假"); return }

该标记的来源也一并核实(同文件传入 StudentActionDialog 的 props):
  "application-id": c.value?.id || s.data?.id
  "project-id":     c.value?.project?.id || c.value?.projectId
  "cancellation-apply-in-area":
      c.value?.cancellationApplyInArea === true
   || c.value?.cancellationApplyInArea === "是"

顺带确认了 originalId 与 projectId 的取值与我们现有实现一致
(ToLeaveItems 的 ID=r.ID、TypeWid=r.ProjectID)。

改动:
- XsgzLeaveRecord 增加 CancellationApplyInArea(用 interface{} 兼容
  bool 与 "是" 两种返回),并加 RequiresInArea() 做等价归一化;
- 新增 recordRequiresInArea:后端自行重查记录判定,不复用前端传值
  (可篡改、且可能未传);查不到时保守返回 false,不额外拦截;
- 销假流程改为:该校要求范围内且实际不在范围内 → 明确报错并拒绝提交,
  与前端一致;未配置范围时 inArea 视为 true。

测试:RequiresInArea 覆盖 bool true/false、"是"/"否"、"true" 字符串、
nil、数字等输入。
This commit is contained in:
becomer
2026-10-17 18:19:27 +08:00
parent 355557b6ce
commit ab4f29a474
4 changed files with 86 additions and 15 deletions
+22 -15
View File
@@ -545,29 +545,36 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
return return
} }
// 销假地理范围校验(对齐前端 LeaveCancelLocation): // 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
// 仅当该校要求「必须在范围内申请」时才强制,否则只上报实际结果。 // v = actionType==="CANCELAPPLY" && cancellationApplyInArea
// 前端:v = actionType==="CANCELAPPLY" && cancellationApplyInArea // if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
// if (v && !canPosition) { 拒绝提交 } // 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
inArea := false // 但仍把真实计算结果放进 inArea 上报给学校端。
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil { if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
// 范围查询失败不阻断:可能该校未配置范围,按不限制处理
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr) slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
inArea = true } else if len(ranges) > 0 {
} else if len(ranges) == 0 {
inArea = true // 前端:范围为空时 canPosition 直接为 true
} else {
lng, lngErr := strconv.ParseFloat(req.Longitude, 64) lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
lat, latErr := strconv.ParseFloat(req.Latitude, 64) lat, latErr := strconv.ParseFloat(req.Latitude, 64)
if lngErr == nil && latErr == nil { if lngErr != nil || latErr != nil {
inArea = cpd.InLocationRange(ranges, lng, lat) slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
} else {
// 无坐标无法判断,交由学校端校验
slog.Warn("xsgz 销假缺少有效坐标,跳过范围判断",
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude) "userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
} else {
inArea = cpd.InLocationRange(ranges, lng, lat)
} }
} }
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
if requiresInArea && !inArea {
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{ body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID, ProjectID: req.ProjectID,
OriginalID: req.LeaveID, OriginalID: req.LeaveID,
+23
View File
@@ -262,3 +262,26 @@ func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveA
func streamCtx() (context.Context, context.CancelFunc) { func streamCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 3*time.Minute) return context.WithTimeout(context.Background(), 3*time.Minute)
} }
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
//
// 前端从列表数据取该标记并作为 prop 传给提交弹窗:
//
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
// || c.value?.cancellationApplyInArea === "是"
//
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按项目重新查询,
// 取不到时保守返回 false(不额外拦截,交由学校端最终校验)。
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
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
}
@@ -160,6 +160,24 @@ type XsgzLeaveRecord struct {
Status string `json:"status"` Status string `json:"status"`
Approved bool `json:"approved"` Approved bool `json:"approved"`
Cancelled bool `json:"cancelled"` Cancelled bool `json:"cancelled"`
// CancellationApplyInArea 该校/该项目是否要求销假必须在范围内申请。
// 前端按 `=== true || === "是"` 归一化(见 LeaveDetailView),
// 因此这里用 interface{} 兼容 bool 与字符串两种返回。
CancellationApplyInArea interface{} `json:"cancellationApplyInArea"`
}
// RequiresInArea 归一化 cancellationApplyInArea,等价于前端:
//
// c.value?.cancellationApplyInArea === true || c.value?.cancellationApplyInArea === "是"
func (r XsgzLeaveRecord) RequiresInArea() bool {
switch v := r.CancellationApplyInArea.(type) {
case bool:
return v
case string:
return v == "是"
default:
return false
}
} }
// XsgzFetchLeavePage 查询请假记录列表(只读)。 // XsgzFetchLeavePage 查询请假记录列表(只读)。
@@ -131,3 +131,26 @@ func TestPointInPolygonDegenerate(t *testing.T) {
t.Fatal("两点不应命中") t.Fatal("两点不应命中")
} }
} }
// TestXsgzLeaveRecordRequiresInArea 归一化前端 `=== true || === "是"` 的语义。
func TestXsgzLeaveRecordRequiresInArea(t *testing.T) {
cases := []struct {
name string
val interface{}
want bool
}{
{"布尔 true", true, true},
{"布尔 false", false, false},
{"字符串 是", "是", true},
{"字符串 否", "否", false},
{"字符串 true", "true", false},
{"nil", nil, false},
{"数字", float64(1), false},
}
for _, c := range cases {
rec := XsgzLeaveRecord{CancellationApplyInArea: c.val}
if got := rec.RequiresInArea(); got != c.want {
t.Fatalf("%s: RequiresInArea()=%v, want %v", c.name, got, c.want)
}
}
}