销假补上地理范围校验: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:
@@ -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("两点不应命中")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user