小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

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