小程序一些功能更新
This commit is contained in:
433
internal/logic/event_query.go
Normal file
433
internal/logic/event_query.go
Normal file
@@ -0,0 +1,433 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user