销假补上地理范围校验:inArea 此前恒为 false,学校端可能直接拒绝

从 sdau 线上源码(LeaveDetailView-BveoXCrD.js)拿到销假提交的真实实现:

  const W = _e(r.value, I.value),          // 构造 fields
  H = { originalId: a.applicationId, projectId: a.projectId,
        requestType: "CANCELLATION", fields: W,
        cancellationApplyInArea: a.cancellationApplyInArea,
        inArea: T?.canPosition || !1,      // ← 客户端地理围栏计算结果
        address, longitude, latitude },
  z = he(H, M.value), B = await Te(z)

前端还有一道硬拦截:
  const v = computed(() => a.actionType==="CANCELAPPLY" && a.cancellationApplyInArea)
  if (v.value && !T?.canPosition) { warning("不在销假申请范围内,无法销假"); return }

而我们的实现里 XsgzSubmitPayload.InArea 从未被赋值,销假请求恒传
inArea=false。若该校 cancellationApplyInArea=true,前端不会提交的请求
我们会照发,结果被学校端拒绝,用户只看到含糊的失败。

新增(均对齐前端):
- XsgzFetchLocationRange:GET /api/leave/student_action/location_range
  (前端 ve/bt,路径由源码核实)
- InLocationRange:复刻前端 c() 的围栏判断——Circle/poi 比球面距离,
  Polygon/Rectangle 走射线法点在多边形内;范围为空时返回 true
  (对应前端 !r.value.length 时 return true)
- 销假流程先拉范围再算 inArea;范围查询失败或无有效坐标时按不限制处理
  并记日志,不让本校未配置范围的情况把功能卡死

复用 task.go 已有的 haversineMeters(注意其签名是 lat1,lon1,lat2,lon2),
未重复造轮子。

测试覆盖:空范围、圆形半径内外、poi、多边形内外、矩形、多范围任一命中、
退化多边形(<3 点)。
This commit is contained in:
becomer
2026-10-17 18:18:09 +08:00
parent 568329890b
commit 355557b6ce
3 changed files with 243 additions and 0 deletions
+25
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -544,12 +545,36 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
return
}
// 销假地理范围校验(对齐前端 LeaveCancelLocation):
// 仅当该校要求「必须在范围内申请」时才强制,否则只上报实际结果。
// 前端:v = actionType==="CANCELAPPLY" && cancellationApplyInArea
// if (v && !canPosition) { 拒绝提交 }
inArea := false
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
// 范围查询失败不阻断:可能该校未配置范围,按不限制处理
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
inArea = true
} else if len(ranges) == 0 {
inArea = true // 前端:范围为空时 canPosition 直接为 true
} else {
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
if lngErr == nil && latErr == nil {
inArea = cpd.InLocationRange(ranges, lng, lat)
} else {
// 无坐标无法判断,交由学校端校验
slog.Warn("xsgz 销假缺少有效坐标,跳过范围判断",
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
}
}
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID,
OriginalID: req.LeaveID,
Address: req.Address,
Longitude: req.Longitude,
Latitude: req.Latitude,
InArea: inArea,
Fields: formFields,
})
if err != nil {
@@ -386,6 +386,91 @@ func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPay
return resp.Body(), nil
}
// XsgzLocationRange 销假地理范围(前端 location_range 的返回项)。
//
// 前端 LeaveCancelLocation 用它做客户端判断:Circle/poi 比半径,
// Polygon/Rectangle 用 GeometryUtil.isPointInPolygon。
type XsgzLocationRange struct {
CompatibleType string `json:"compatibleType"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Radius float64 `json:"radius"`
// Points 仅多边形/矩形有值
Points []struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
} `json:"points"`
}
// XsgzFetchLocationRange 查询指定请假项目的销假地理范围(只读)。
// 对应前端 GET /api/leave/student_action/location_range?projectId=...
func (s *XsgzSession) XsgzFetchLocationRange(ctx context.Context, projectID string) ([]XsgzLocationRange, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/location_range",
map[string]string{"projectId": projectID}, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data []XsgzLocationRange `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析销假范围失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("查询销假范围失败: %s", result.Message)
}
return result.Data, nil
}
// InLocationRange 判断坐标是否落在任一销假范围内,等价于前端 canPosition。
//
// 前端实现(LeaveCancelLocation 的 c()):
// - Circle / poi:球面距离 <= radius;
// - Polygon / Rectangle:点是否在多边形内;
// - 范围列表为空时前端直接 return true(不限制)。
func InLocationRange(ranges []XsgzLocationRange, lng, lat float64) bool {
if len(ranges) == 0 {
return true // 前端:!r.value.length 时 return true
}
for _, r := range ranges {
switch r.CompatibleType {
case "Circle", "poi":
// 复用 task.go 的 haversineMeters(签名:lat1, lon1, lat2, lon2)
if haversineMeters(lat, lng, r.Latitude, r.Longitude) <= r.Radius {
return true
}
case "Polygon", "Rectangle":
if pointInPolygon(lng, lat, r.Points) {
return true
}
}
}
return false
}
// pointInPolygon 射线法判断点是否在多边形内(含顶点/边界的近似处理)。
func pointInPolygon(lng, lat float64, pts []struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
}) bool {
n := len(pts)
if n < 3 {
return false
}
inside := false
for i, j := 0, n-1; i < n; j, i = i, i+1 {
xi, yi := pts[i].Longitude, pts[i].Latitude
xj, yj := pts[j].Longitude, pts[j].Latitude
if (yi > lat) != (yj > lat) &&
lng < (xj-xi)*(lat-yi)/(yj-yi)+xi {
inside = !inside
}
}
return inside
}
// XsgzFetchIsRequireLocation 查询该校请假是否强制要求定位
func (s *XsgzSession) XsgzFetchIsRequireLocation(ctx context.Context) (bool, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/is_require_location", nil, nil)
@@ -0,0 +1,133 @@
package cpdaily
import "testing"
// 销假地理范围判断,对齐前端 LeaveCancelLocation 的 c():
// Circle/poi 比球面距离,Polygon/Rectangle 用点在多边形内。
type pt = struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
}
// TestInLocationRangeEmpty 范围为空时不限制(前端:!r.value.length 时 return true)。
func TestInLocationRangeEmpty(t *testing.T) {
if !InLocationRange(nil, 117.12, 36.19) {
t.Fatal("范围为空应视为不限制")
}
if !InLocationRange([]XsgzLocationRange{}, 117.12, 36.19) {
t.Fatal("空切片应视为不限制")
}
}
// TestInLocationRangeCircle 圆形范围:半径内 true,半径外 false。
func TestInLocationRangeCircle(t *testing.T) {
// 山东农业大学附近,半径 500 米
center := XsgzLocationRange{
CompatibleType: "Circle",
Longitude: 117.12,
Latitude: 36.19,
Radius: 500,
}
// 圆心
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.19) {
t.Fatal("圆心应在范围内")
}
// 约 111 米外(纬度 0.001 度 ≈ 111 米)
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.191) {
t.Fatal("111 米外应在半径内")
}
// 约 1.1 公里外(0.01 度 ≈ 1111 米),应超出
if InLocationRange([]XsgzLocationRange{center}, 117.12, 36.20) {
t.Fatal("1.1 公里外应超出 500 米半径")
}
}
// TestInLocationRangePoi poi 类型按圆形处理。
func TestInLocationRangePoi(t *testing.T) {
r := XsgzLocationRange{
CompatibleType: "poi",
Longitude: 117.12,
Latitude: 36.19,
Radius: 200,
}
if !InLocationRange([]XsgzLocationRange{r}, 117.12, 36.19) {
t.Fatal("poi 中心应在范围内")
}
if InLocationRange([]XsgzLocationRange{r}, 117.12, 36.25) {
t.Fatal("远处应在范围外")
}
}
// TestInLocationRangePolygon 多边形:内部 true,外部 false。
func TestInLocationRangePolygon(t *testing.T) {
// 一个经纬度平面上的正方形,覆盖 117.10~117.14 × 36.17~36.21
poly := XsgzLocationRange{
CompatibleType: "Polygon",
Points: []pt{
{Longitude: 117.10, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.21},
{Longitude: 117.10, Latitude: 36.21},
},
}
ranges := []XsgzLocationRange{poly}
if !InLocationRange(ranges, 117.12, 36.19) {
t.Fatal("中心点应在多边形内")
}
if InLocationRange(ranges, 117.20, 36.19) {
t.Fatal("右侧外部点不应在多边形内")
}
if InLocationRange(ranges, 117.12, 36.30) {
t.Fatal("上方外部点不应在多边形内")
}
}
// TestInLocationRangeRectangle 矩形按多边形处理。
func TestInLocationRangeRectangle(t *testing.T) {
rect := XsgzLocationRange{
CompatibleType: "Rectangle",
Points: []pt{
{Longitude: 117.10, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.21},
{Longitude: 117.10, Latitude: 36.21},
},
}
if !InLocationRange([]XsgzLocationRange{rect}, 117.13, 36.18) {
t.Fatal("矩形内点应命中")
}
if InLocationRange([]XsgzLocationRange{rect}, 117.15, 36.18) {
t.Fatal("矩形外点不应命中")
}
}
// TestInLocationRangeMultiple 任一范围命中即可。
func TestInLocationRangeMultiple(t *testing.T) {
ranges := []XsgzLocationRange{
{CompatibleType: "Circle", Longitude: 117.12, Latitude: 36.19, Radius: 100},
{CompatibleType: "Circle", Longitude: 117.50, Latitude: 36.50, Radius: 100},
}
if !InLocationRange(ranges, 117.50, 36.50) {
t.Fatal("第二个圆应命中")
}
if InLocationRange(ranges, 118.00, 37.00) {
t.Fatal("都不应命中")
}
}
// TestPointInPolygonDegenerate 少于 3 个点时不构成多边形。
func TestPointInPolygonDegenerate(t *testing.T) {
if pointInPolygon(0, 0, nil) {
t.Fatal("空点集不应命中")
}
one := []pt{{Longitude: 0, Latitude: 0}}
if pointInPolygon(0, 0, one) {
t.Fatal("单点不应命中")
}
two := []pt{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
if pointInPolygon(0.5, 0.5, two) {
t.Fatal("两点不应命中")
}
}