Files
qitongxue-api/internal/logic/event_query.go
2026-10-09 16:49:36 +08:00

521 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logic
import (
"context"
"encoding/json"
"errors"
"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) 姓名/手机号**二选一**即可发起查询(C 端体验);命中**重名**时返回 ambiguous
// 标记且**零座位信息**,引导补手机号二次确认——绝不因为"只输了名字"就漏出别人的桌号;
// 未命中文案统一模糊(防枚举);
// 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 "未查询到您的座位信息,请联系活动组织者确认是否已录入"
}
// seatAmbiguousMessage 命中重名时的引导文案(**不透露命中了几条**,只让用户补手机号)
func seatAmbiguousMessage() string {
return "有多个同名宾客,请补充手机号后再查询"
}
// errSeatAmbiguous 命中重名的哨兵错误:lookupSeat 返回它,调用方回 4010 且不下发座位信息。
//
// 🔴 用哨兵 error 而非第二个返回值,是为了让「未命中(模糊文案,正常返回体)」
// 与「重名(需消歧,错误码)」在类型上就分得开,不会被后续维护误合并。
var errSeatAmbiguous = gerror.NewCode(
gcode.New(consts.CodeSeatAmbiguous, "重名需消歧", nil),
seatAmbiguousMessage(),
)
// ============================================================================
// 纯函数
// ============================================================================
// 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 查座入参校验:**姓名 / 手机号二选一非空即可**(均空才拒)。
//
// 🔴 与旧版「双因子都必填」的差异(迭代-2026-10-09):C 端只要能定位到人即可发起查询,
//
// 双因子作为**加强**因子而非**前置**因子。重名的消歧由 lookupSeat 返回 ambiguous 兜住。
func validSeatQueryInput(name, phone string) bool {
hasName := name != ""
hasPhone := phone != ""
// 两个都空 → 拒(防无意义查询打满限流配额)
if !hasName && !hasPhone {
return false
}
if hasName && len([]rune(name)) > 32 {
return false
}
// 填了手机号就必须合法(没填不算错)
if hasPhone && !validPhone(phone) {
return false
}
return true
}
// ============================================================================
// 公开接口
// ============================================================================
// 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)
// 🔴 三种失败原因要给**各自对应**的文案(迭代-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, "请填写姓名或手机号")
}
}
lookup, err := lookupSeat(ctx, req.EventId, name, phone)
if err != nil {
// 🔴 命中重名:**返回 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
}
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 查座位:name / phone **二选一或都填**均可查询。
//
// 匹配语义:
// - 两者都填 → 双因子精确匹配(等价于旧行为);
// - 只给一个 → 单因子匹配;命中**多行**(重名)时返回 ambiguous=nil 行语义,
// 由调用方回 4010 并**不下发任何座位信息**(见 seatAmbiguous 哨兵)。
//
// 返回 (nil, nil) 表示未命中(前端展示统一模糊文案);返回 nil lookup + ambiguousErr
// 表示命中重名、需补手机号消歧。
func lookupSeat(ctx context.Context, eventId int64, name, phone string) (*seatLookup, error) {
// 基础 JOIN:本人须在企业员工库、且在该活动候场名单、且已绑定座位。
// ⚠️ 这里**不**用 DISTINCT:同名同人的多行正是「重名」的判据。
const baseSQL = "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 = ? "
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...)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, nil
}
// 🔴 重名:不返回任何座位信息,交由调用方回 4010 引导补手机号
if len(records) > 1 {
return nil, errSeatAmbiguous
}
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()
}