Files
qitongxue-api/internal/logic/event_query.go
2026-09-29 10:57:01 +08:00

434 lines
13 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"
"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()
}