2026-09-29 10:57:01 +08:00
|
|
|
|
package logic
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
2026-10-09 16:49:36 +08:00
|
|
|
|
"errors"
|
2026-09-29 10:57:01 +08:00
|
|
|
|
"sort"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gogf/gf/v2/errors/gcode"
|
|
|
|
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
|
|
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
|
|
|
|
|
|
|
|
|
|
v1 "tool-api/api/user/v1"
|
|
|
|
|
|
"tool-api/internal/consts"
|
|
|
|
|
|
"tool-api/internal/model/entity"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 年会域:公开查座(免登录)
|
|
|
|
|
|
// 依据:PRD-02 §4.2.3(鉴权与隐私边界)、R7-11~R7-24;架构 §3.2.9、§4.4 图④、§8.6.2。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 三条红线:
|
|
|
|
|
|
// 1) 只查已发布活动(status=published),否则 4005;
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// 2) 姓名/手机号**二选一**即可发起查询(C 端体验);命中**重名**时返回 ambiguous
|
|
|
|
|
|
// 标记且**零座位信息**,引导补手机号二次确认——绝不因为"只输了名字"就漏出别人的桌号;
|
|
|
|
|
|
// 未命中文案统一模糊(防枚举);
|
2026-09-29 10:57:01 +08:00
|
|
|
|
// 3) 结果最小化:只返回本人桌座 + 本人所在桌示意,绝不返回他人信息 / 名单 / 统计。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 限流/冷却无 Redis,以 event_seat_queries 时间窗 COUNT 为准(表为权威源)。
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// seatLookup 命中结果(本人所在桌座)
|
|
|
|
|
|
type seatLookup struct {
|
|
|
|
|
|
TableId int64
|
|
|
|
|
|
TableNo string
|
|
|
|
|
|
TableName string
|
|
|
|
|
|
SeatNo int
|
|
|
|
|
|
Capacity int
|
|
|
|
|
|
// 本人桌圆心(hall_layout 大厅逻辑坐标系;缺省 0)
|
|
|
|
|
|
TableX int
|
|
|
|
|
|
TableY int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// seatGuardInput 限流/冷却判定入参(纯函数入参,便于单测)
|
|
|
|
|
|
type seatGuardInput struct {
|
|
|
|
|
|
CountLastMinute int
|
|
|
|
|
|
CountLastHour int
|
|
|
|
|
|
ConsecutiveFails int
|
|
|
|
|
|
LastFailAt *time.Time
|
|
|
|
|
|
Now time.Time
|
|
|
|
|
|
CooldownMinutes int
|
|
|
|
|
|
Threshold int
|
|
|
|
|
|
PerMinute int
|
|
|
|
|
|
PerHour int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func gcodeEventUnavailable() gcode.Code {
|
|
|
|
|
|
return gcode.New(consts.CodeEventUnavailable, "", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func gcodeSeat(code int) gcode.Code {
|
|
|
|
|
|
return gcode.New(code, "", nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// seatBusyMessage 4006/4007 共用文案(§8.1 允许前端合并展示)
|
|
|
|
|
|
func seatBusyMessage() string {
|
|
|
|
|
|
return "操作过于频繁,请稍后再试"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// seatNotFoundMessage 双因子不匹配/未录入/未分配座位共用文案(防枚举)
|
|
|
|
|
|
func seatNotFoundMessage() string {
|
|
|
|
|
|
return "未查询到您的座位信息,请联系活动组织者确认是否已录入"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// seatAmbiguousMessage 命中重名时的引导文案(**不透露命中了几条**,只让用户补手机号)
|
|
|
|
|
|
func seatAmbiguousMessage() string {
|
|
|
|
|
|
return "有多个同名宾客,请补充手机号后再查询"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// errSeatAmbiguous 命中重名的哨兵错误:lookupSeat 返回它,调用方回 4010 且不下发座位信息。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 🔴 用哨兵 error 而非第二个返回值,是为了让「未命中(模糊文案,正常返回体)」
|
|
|
|
|
|
// 与「重名(需消歧,错误码)」在类型上就分得开,不会被后续维护误合并。
|
|
|
|
|
|
var errSeatAmbiguous = gerror.NewCode(
|
|
|
|
|
|
gcode.New(consts.CodeSeatAmbiguous, "重名需消歧", nil),
|
|
|
|
|
|
seatAmbiguousMessage(),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-29 10:57:01 +08:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 纯函数
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// judgeSeatGuard 返回限流判定码:0 放行 / 4006 超频 / 4007 冷却中。
|
|
|
|
|
|
func judgeSeatGuard(in seatGuardInput) int {
|
|
|
|
|
|
if in.PerMinute > 0 && in.CountLastMinute >= in.PerMinute {
|
|
|
|
|
|
return consts.CodeSeatRateLimited
|
|
|
|
|
|
}
|
|
|
|
|
|
if in.PerHour > 0 && in.CountLastHour >= in.PerHour {
|
|
|
|
|
|
return consts.CodeSeatRateLimited
|
|
|
|
|
|
}
|
|
|
|
|
|
if in.LastFailAt != nil && in.Threshold > 0 && in.ConsecutiveFails >= in.Threshold {
|
|
|
|
|
|
cooldown := time.Duration(in.CooldownMinutes) * time.Minute
|
|
|
|
|
|
if cooldown > 0 && in.Now.Sub(*in.LastFailAt) < cooldown {
|
|
|
|
|
|
return consts.CodeSeatCooling
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// consecutiveFails 计算最近连续失败次数(results 需按时间倒序,最近在前)。
|
|
|
|
|
|
func consecutiveFails(results []int) int {
|
|
|
|
|
|
n := 0
|
|
|
|
|
|
for _, r := range results {
|
|
|
|
|
|
if r == 0 {
|
|
|
|
|
|
n++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
return n
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// maskPhone 手机号脱敏:中间 4 位打码(138****0000,PRD-02 Q15)。
|
|
|
|
|
|
func maskPhone(p string) string {
|
|
|
|
|
|
if p == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(p) <= 4 {
|
|
|
|
|
|
return strings.Repeat("*", len(p))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(p) >= 7 {
|
|
|
|
|
|
return p[:3] + "****" + p[len(p)-4:]
|
|
|
|
|
|
}
|
|
|
|
|
|
return p[:2] + "***" + p[len(p)-2:]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseStage 从 hall_layout JSON 解析舞台信息;无舞台/解析失败返回 nil(不阻断查座)。
|
|
|
|
|
|
func parseStage(layout string) *v1.StageOut {
|
|
|
|
|
|
if strings.TrimSpace(layout) == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
var parsed struct {
|
|
|
|
|
|
Stage struct {
|
|
|
|
|
|
X int `json:"x"`
|
|
|
|
|
|
Y int `json:"y"`
|
|
|
|
|
|
W int `json:"w"`
|
|
|
|
|
|
H int `json:"h"`
|
|
|
|
|
|
Shape string `json:"shape"`
|
|
|
|
|
|
Rotation int `json:"rotation"`
|
|
|
|
|
|
Color string `json:"color"`
|
|
|
|
|
|
} `json:"stage"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(layout), &parsed); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
s := parsed.Stage
|
|
|
|
|
|
if s.W == 0 && s.H == 0 && s.Shape == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &v1.StageOut{X: s.X, Y: s.Y, W: s.W, H: s.H, Shape: s.Shape, Rotation: s.Rotation, Color: s.Color}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 大厅逻辑坐标系缺省尺寸(与设计器 hall_layout.canvas 缺省一致:qitongxue-admin Designer.vue canvas:{w:1600,h:1200})。
|
|
|
|
|
|
const (
|
|
|
|
|
|
defaultHallW = 1600
|
|
|
|
|
|
defaultHallH = 1200
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// parseCanvas 从 hall_layout JSON 解析大厅逻辑宽高(canvas.w / canvas.h)。
|
|
|
|
|
|
// 空串 / 解析失败 / 值 ≤0 → 回退 1600×1200(不阻断查座,风格与 parseStage 一致)。
|
|
|
|
|
|
func parseCanvas(layout string) (w, h int) {
|
|
|
|
|
|
w, h = defaultHallW, defaultHallH
|
|
|
|
|
|
if strings.TrimSpace(layout) == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
var parsed struct {
|
|
|
|
|
|
Canvas struct {
|
|
|
|
|
|
W int `json:"w"`
|
|
|
|
|
|
H int `json:"h"`
|
|
|
|
|
|
} `json:"canvas"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(layout), &parsed); err != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if parsed.Canvas.W > 0 {
|
|
|
|
|
|
w = parsed.Canvas.W
|
|
|
|
|
|
}
|
|
|
|
|
|
if parsed.Canvas.H > 0 {
|
|
|
|
|
|
h = parsed.Canvas.H
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildSeatCells 生成「本人所在桌」示意:只含座位号 + 是否本人 + 是否有人(不含身份)。
|
|
|
|
|
|
func buildSeatCells(seats []seatBrief, selfNo int) []v1.SeatCell {
|
|
|
|
|
|
sorted := append([]seatBrief(nil), seats...)
|
|
|
|
|
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].SeatNo < sorted[j].SeatNo })
|
|
|
|
|
|
out := make([]v1.SeatCell, 0, len(sorted))
|
|
|
|
|
|
for _, s := range sorted {
|
|
|
|
|
|
out = append(out, v1.SeatCell{
|
|
|
|
|
|
SeatNo: s.SeatNo,
|
|
|
|
|
|
IsSelf: s.SeatNo == selfNo,
|
|
|
|
|
|
Filled: s.EmployeeId != 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// validSeatQueryInput 查座入参校验:**姓名 / 手机号二选一非空即可**(均空才拒)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 🔴 与旧版「双因子都必填」的差异(迭代-2026-10-09):C 端只要能定位到人即可发起查询,
|
|
|
|
|
|
//
|
|
|
|
|
|
// 双因子作为**加强**因子而非**前置**因子。重名的消歧由 lookupSeat 返回 ambiguous 兜住。
|
2026-09-29 10:57:01 +08:00
|
|
|
|
func validSeatQueryInput(name, phone string) bool {
|
2026-10-09 16:49:36 +08:00
|
|
|
|
hasName := name != ""
|
|
|
|
|
|
hasPhone := phone != ""
|
|
|
|
|
|
// 两个都空 → 拒(防无意义查询打满限流配额)
|
|
|
|
|
|
if !hasName && !hasPhone {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if hasName && len([]rune(name)) > 32 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
// 填了手机号就必须合法(没填不算错)
|
|
|
|
|
|
if hasPhone && !validPhone(phone) {
|
2026-09-29 10:57:01 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-10-09 16:49:36 +08:00
|
|
|
|
return true
|
2026-09-29 10:57:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 公开接口
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// EventInfo 活动公开信息(查座页顶部)。活动不存在 → 4005;未发布时 CanQuery=false。
|
|
|
|
|
|
func EventInfo(ctx context.Context, req *v1.EventInfoReq) (*v1.EventInfoRes, error) {
|
|
|
|
|
|
event, err := publicEventRow(ctx, req.EventId)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return &v1.EventInfoRes{
|
|
|
|
|
|
Title: event.Title,
|
|
|
|
|
|
Venue: event.Venue,
|
|
|
|
|
|
EventTime: formatGTime(event.EventTime),
|
|
|
|
|
|
Status: event.Status,
|
|
|
|
|
|
CanQuery: event.Status == consts.EventStatusPublished,
|
|
|
|
|
|
Stage: parseStage(event.HallLayout),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EventSeatQuery 扫码查座(免登录):限流 → 双因子精确匹配 → 结果最小化 → 审计。
|
|
|
|
|
|
func EventSeatQuery(ctx context.Context, req *v1.EventSeatQueryReq) (*v1.EventSeatQueryRes, error) {
|
|
|
|
|
|
event, err := publicEventRow(ctx, req.EventId)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if event.Status != consts.EventStatusPublished {
|
|
|
|
|
|
return nil, gerror.NewCode(gcodeEventUnavailable(), "活动未开放查询")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ip := clientIp(ctx)
|
|
|
|
|
|
openid := "" // 查座免登录,无 openid
|
|
|
|
|
|
|
|
|
|
|
|
// 限流 / 冷却闸门(被拦的请求不写审计,避免自锁)
|
|
|
|
|
|
if code := seatGuardVerdict(ctx, req.EventId, ip); code != 0 {
|
|
|
|
|
|
return nil, gerror.NewCode(gcodeSeat(code), seatBusyMessage())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
|
|
|
|
phone := normalizePhone(req.Phone)
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// 参数异常(两个都空 / 手机号格式错 / 姓名超长)→ 拒绝,并计入一次失败(防刷)
|
2026-09-29 10:57:01 +08:00
|
|
|
|
if !validSeatQueryInput(name, phone) {
|
|
|
|
|
|
writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0)
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// 🔴 三种失败原因要给**各自对应**的文案(迭代-2026-10-09):
|
|
|
|
|
|
// 统一回「请填写姓名或手机号」会在「姓名已填、只有手机号格式错」时误导用户
|
|
|
|
|
|
// 去反复填姓名(实测截图即此问题)。顺序按「最具体 → 最宽泛」,
|
|
|
|
|
|
// 与 validSeatQueryInput 内部的判定顺序一致,便于逐条对应。
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case phone != "" && !validPhone(phone):
|
|
|
|
|
|
// 手机号填了但不合法 —— 明确指向手机号,避免用户去改已填对的姓名
|
|
|
|
|
|
return nil, gerror.NewCode(gcode.CodeValidationFailed, "手机号格式不正确,请填写 11 位手机号")
|
|
|
|
|
|
case name != "" && len([]rune(name)) > 32:
|
|
|
|
|
|
return nil, gerror.NewCode(gcode.CodeValidationFailed, "姓名过长,请检查后重新输入")
|
|
|
|
|
|
default:
|
|
|
|
|
|
// 两项都空
|
|
|
|
|
|
return nil, gerror.NewCode(gcode.CodeValidationFailed, "请填写姓名或手机号")
|
|
|
|
|
|
}
|
2026-09-29 10:57:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lookup, err := lookupSeat(ctx, req.EventId, name, phone)
|
|
|
|
|
|
if err != nil {
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// 🔴 命中重名:**返回 ambiguous 标记 + 零座位信息**(不是 404/模糊文案)。
|
|
|
|
|
|
// 前端据此把手机号输入框升为必填做二次查询。
|
|
|
|
|
|
if errors.Is(err, errSeatAmbiguous) || gerror.Code(err).Code() == consts.CodeSeatAmbiguous {
|
|
|
|
|
|
writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0)
|
|
|
|
|
|
return &v1.EventSeatQueryRes{
|
|
|
|
|
|
Found: false,
|
|
|
|
|
|
Ambiguous: true,
|
|
|
|
|
|
Message: seatAmbiguousMessage(),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2026-09-29 10:57:01 +08:00
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if lookup == nil {
|
|
|
|
|
|
writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0)
|
|
|
|
|
|
return &v1.EventSeatQueryRes{Found: false, Message: seatNotFoundMessage()}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
res, err := buildSeatQueryRes(ctx, event, lookup)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
writeSeatAudit(ctx, req.EventId, openid, ip, phone, 1)
|
|
|
|
|
|
return res, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 内部实现
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// publicEventRow 读取活动;不存在 → 4005。
|
|
|
|
|
|
func publicEventRow(ctx context.Context, eventId int64) (*entity.AnnualEvents, error) {
|
|
|
|
|
|
record, err := g.Model(consts.TableAnnualEvents).Where("id", eventId).One()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if record.IsEmpty() {
|
|
|
|
|
|
return nil, gerror.NewCode(gcodeEventUnavailable(), "活动不存在或未开放查询")
|
|
|
|
|
|
}
|
|
|
|
|
|
event := &entity.AnnualEvents{}
|
|
|
|
|
|
if err = record.Struct(event); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return event, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// lookupSeat 查座位:name / phone **二选一或都填**均可查询。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 匹配语义:
|
|
|
|
|
|
// - 两者都填 → 双因子精确匹配(等价于旧行为);
|
|
|
|
|
|
// - 只给一个 → 单因子匹配;命中**多行**(重名)时返回 ambiguous=nil 行语义,
|
|
|
|
|
|
// 由调用方回 4010 并**不下发任何座位信息**(见 seatAmbiguous 哨兵)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 返回 (nil, nil) 表示未命中(前端展示统一模糊文案);返回 nil lookup + ambiguousErr
|
|
|
|
|
|
// 表示命中重名、需补手机号消歧。
|
2026-09-29 10:57:01 +08:00
|
|
|
|
func lookupSeat(ctx context.Context, eventId int64, name, phone string) (*seatLookup, error) {
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// 基础 JOIN:本人须在企业员工库、且在该活动候场名单、且已绑定座位。
|
|
|
|
|
|
// ⚠️ 这里**不**用 DISTINCT:同名同人的多行正是「重名」的判据。
|
|
|
|
|
|
const baseSQL = "SELECT s.table_id AS table_id, s.seat_no AS seat_no " +
|
2026-09-29 10:57:01 +08:00
|
|
|
|
"FROM employees e " +
|
|
|
|
|
|
"JOIN event_participants p ON p.employee_id = e.id AND p.event_id = ? " +
|
2026-10-09 16:49:36 +08:00
|
|
|
|
"JOIN event_seats s ON s.employee_id = e.id AND s.event_id = ? "
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
sql string
|
|
|
|
|
|
args []any
|
|
|
|
|
|
)
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case name != "" && phone != "":
|
|
|
|
|
|
sql = baseSQL + "WHERE e.name = ? AND e.phone = ? LIMIT 2"
|
|
|
|
|
|
args = []any{eventId, eventId, name, phone}
|
|
|
|
|
|
case name != "":
|
|
|
|
|
|
// 只按姓名:LIMIT 2 足够判定「是否重名」(0 行=未命中,1 行=唯一,≥2 行=重名)
|
|
|
|
|
|
sql = baseSQL + "WHERE e.name = ? LIMIT 2"
|
|
|
|
|
|
args = []any{eventId, eventId, name}
|
|
|
|
|
|
default:
|
|
|
|
|
|
// 只按手机号:手机号在本企业内唯一,命中即本人
|
|
|
|
|
|
sql = baseSQL + "WHERE e.phone = ? LIMIT 2"
|
|
|
|
|
|
args = []any{eventId, eventId, phone}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
records, err := g.DB().GetAll(ctx, sql, args...)
|
2026-09-29 10:57:01 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(records) == 0 {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
2026-10-09 16:49:36 +08:00
|
|
|
|
// 🔴 重名:不返回任何座位信息,交由调用方回 4010 引导补手机号
|
|
|
|
|
|
if len(records) > 1 {
|
|
|
|
|
|
return nil, errSeatAmbiguous
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 10:57:01 +08:00
|
|
|
|
tableId := records[0]["table_id"].Int64()
|
|
|
|
|
|
seatNo := records[0]["seat_no"].Int()
|
|
|
|
|
|
|
|
|
|
|
|
table, err := g.Model(consts.TableEventTables).Where("id", tableId).Where("event_id", eventId).One()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if table.IsEmpty() {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &seatLookup{
|
|
|
|
|
|
TableId: tableId,
|
|
|
|
|
|
TableNo: table["table_no"].String(),
|
|
|
|
|
|
TableName: table["name"].String(),
|
|
|
|
|
|
SeatNo: seatNo,
|
|
|
|
|
|
Capacity: table["capacity"].Int(),
|
|
|
|
|
|
TableX: table["x"].Int(),
|
|
|
|
|
|
TableY: table["y"].Int(),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildSeatQueryRes 组装最小化返回体(本人桌座 + 本桌示意 + 舞台)。
|
|
|
|
|
|
func buildSeatQueryRes(ctx context.Context, event *entity.AnnualEvents, lookup *seatLookup) (*v1.EventSeatQueryRes, error) {
|
|
|
|
|
|
briefs, err := loadSeatBriefs(ctx, g.DB(), event.Id, lookup.TableId)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
// hall_w / hall_h 取自 hall_layout.canvas(缺省 1600/1200)——只用于前端缩放,
|
|
|
|
|
|
// 与舞台同源解析,不引入额外查询。
|
|
|
|
|
|
hallW, hallH := parseCanvas(event.HallLayout)
|
|
|
|
|
|
return &v1.EventSeatQueryRes{
|
|
|
|
|
|
Found: true,
|
|
|
|
|
|
Message: "ok",
|
|
|
|
|
|
TableNo: lookup.TableNo,
|
|
|
|
|
|
TableName: lookup.TableName,
|
|
|
|
|
|
SeatNo: lookup.SeatNo,
|
|
|
|
|
|
Capacity: lookup.Capacity,
|
|
|
|
|
|
Seats: buildSeatCells(briefs, lookup.SeatNo),
|
|
|
|
|
|
Stage: parseStage(event.HallLayout),
|
|
|
|
|
|
TableX: lookup.TableX,
|
|
|
|
|
|
TableY: lookup.TableY,
|
|
|
|
|
|
HallW: hallW,
|
|
|
|
|
|
HallH: hallH,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// seatGuardVerdict 计算限流/冷却判定码(0 放行)。查询异常时放行(宁漏不误伤)。
|
|
|
|
|
|
func seatGuardVerdict(ctx context.Context, eventId int64, ip string) int {
|
|
|
|
|
|
perMin, perHour, cooldownMin, threshold := seatGuardParams(ctx)
|
|
|
|
|
|
now := gtime.Now()
|
|
|
|
|
|
|
|
|
|
|
|
cntMin, err := g.Model(consts.TableEventSeatQueries).
|
|
|
|
|
|
Where("event_id", eventId).Where("ip", ip).
|
|
|
|
|
|
WhereGTE("created_at", now.Add(-time.Minute)).Count()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
cntHour, err := g.Model(consts.TableEventSeatQueries).
|
|
|
|
|
|
Where("event_id", eventId).Where("ip", ip).
|
|
|
|
|
|
WhereGTE("created_at", now.Add(-time.Hour)).Count()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
rows, err := g.Model(consts.TableEventSeatQueries).
|
|
|
|
|
|
Where("event_id", eventId).Where("ip", ip).
|
|
|
|
|
|
OrderDesc("id").Limit(20).All()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
results := make([]int, 0, len(rows))
|
|
|
|
|
|
var lastFailAt *time.Time
|
|
|
|
|
|
for i, r := range rows {
|
|
|
|
|
|
res := r["result"].Int()
|
|
|
|
|
|
results = append(results, res)
|
|
|
|
|
|
if i == 0 && res == 0 {
|
|
|
|
|
|
if t := r["created_at"].GTime(); t != nil {
|
|
|
|
|
|
lastFailAt = &t.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return judgeSeatGuard(seatGuardInput{
|
|
|
|
|
|
CountLastMinute: cntMin,
|
|
|
|
|
|
CountLastHour: cntHour,
|
|
|
|
|
|
ConsecutiveFails: consecutiveFails(results),
|
|
|
|
|
|
LastFailAt: lastFailAt,
|
|
|
|
|
|
Now: now.Time,
|
|
|
|
|
|
CooldownMinutes: cooldownMin,
|
|
|
|
|
|
Threshold: threshold,
|
|
|
|
|
|
PerMinute: perMin,
|
|
|
|
|
|
PerHour: perHour,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// seatGuardParams 读取阈值(settings 覆盖 > 缺省)。
|
|
|
|
|
|
func seatGuardParams(ctx context.Context) (perMin, perHour, cooldownMin, threshold int) {
|
|
|
|
|
|
perMin = settingInt(ctx, consts.SettingSeatRateLimitPerMin, consts.DefaultSeatRateLimitPerMin)
|
|
|
|
|
|
perHour = settingInt(ctx, consts.SettingSeatRateLimitPerHour, consts.DefaultSeatRateLimitPerHour)
|
|
|
|
|
|
cooldownMin = settingInt(ctx, consts.SettingSeatFailCooldownMinutes, consts.DefaultSeatFailCooldownMinutes)
|
|
|
|
|
|
threshold = settingInt(ctx, consts.SettingSeatFailThreshold, consts.DefaultSeatFailThreshold)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// settingInt 读 settings 整数配置;缺失/非法用默认值。
|
|
|
|
|
|
func settingInt(ctx context.Context, key string, def int) int {
|
|
|
|
|
|
raw := SettingValue(ctx, key)
|
|
|
|
|
|
if v, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && v > 0 {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
return def
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// writeSeatAudit 写审计(成功/失败都写),手机号脱敏。
|
|
|
|
|
|
func writeSeatAudit(ctx context.Context, eventId int64, openid, ip, phone string, result int) {
|
|
|
|
|
|
if _, err := g.Model(consts.TableEventSeatQueries).Data(g.Map{
|
|
|
|
|
|
"event_id": eventId,
|
|
|
|
|
|
"openid": openid,
|
|
|
|
|
|
"ip": ip,
|
|
|
|
|
|
"phone_masked": maskPhone(phone),
|
|
|
|
|
|
"result": result,
|
|
|
|
|
|
"created_at": gtime.Now(),
|
|
|
|
|
|
}).Insert(); err != nil {
|
|
|
|
|
|
g.Log().Warningf(ctx, "[event-seat] 写查座审计失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// clientIp 取客户端 IP(无请求上下文时为空串)。
|
|
|
|
|
|
func clientIp(ctx context.Context) string {
|
|
|
|
|
|
req := g.RequestFromCtx(ctx)
|
|
|
|
|
|
if req == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return req.GetClientIp()
|
|
|
|
|
|
}
|