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