package logic import ( "context" "encoding/json" "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; // 2) 姓名+手机号双因子精确匹配,失败文案统一模糊(防枚举); // 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 "未查询到您的座位信息,请联系活动组织者确认是否已录入" } // ============================================================================ // 纯函数 // ============================================================================ // 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 } // validSeatQueryInput 查座入参校验(姓名非空≤32、手机号为 6~20 位数字)。 func validSeatQueryInput(name, phone string) bool { if name == "" || len([]rune(name)) > 32 { return false } return validPhone(phone) } // ============================================================================ // 公开接口 // ============================================================================ // 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) // 参数异常 → 拒绝,并计入一次失败(防刷) if !validSeatQueryInput(name, phone) { writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0) return nil, gerror.NewCode(gcode.CodeValidationFailed, "请填写正确的姓名与手机号") } lookup, err := lookupSeat(ctx, req.EventId, name, phone) if err != nil { 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 } // lookupSeat 双因子精确匹配:本人须在企业员工库、且在该活动候场名单、且已绑定座位。 func lookupSeat(ctx context.Context, eventId int64, name, phone string) (*seatLookup, error) { const sql = "SELECT s.table_id AS table_id, s.seat_no AS seat_no " + "FROM employees e " + "JOIN event_participants p ON p.employee_id = e.id AND p.event_id = ? " + "JOIN event_seats s ON s.employee_id = e.id AND s.event_id = ? " + "WHERE e.name = ? AND e.phone = ? LIMIT 1" records, err := g.DB().GetAll(ctx, sql, eventId, eventId, name, phone) if err != nil { return nil, err } if len(records) == 0 { return nil, nil } 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() }