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()
|
|||
|
|
}
|