小程序一些功能更新
This commit is contained in:
@@ -41,7 +41,15 @@ type EventInfoRes struct {
|
||||
Stage *StageOut `json:"stage"` // 用于定位,不含个人信息
|
||||
}
|
||||
|
||||
// EventSeatQueryReq 姓名 + 手机号双因子精确匹配(都填且都对才返回)
|
||||
// EventSeatQueryReq 查座入参:**姓名 / 手机号二选一即可发起查询**(C 端体验)。
|
||||
//
|
||||
// 匹配策略(后端 event_query.go 权威实现):
|
||||
// - 只给 name → 按姓名匹配。若命中多张票(重名)→ 返回 4010 ambiguous,**不返回任何座位信息**,
|
||||
// 前端引导用户补手机号做二次查询;
|
||||
// - 只给 phone → 手机号在本企业内唯一,直接精确匹配;
|
||||
// - 两个都给 → 双因子精确匹配(最安全)。
|
||||
//
|
||||
// 🔴 无论哪种组合,**重名时一律不返回座位信息**(4010),避免「输个名字就看到别人的桌号」。
|
||||
type EventSeatQueryReq struct {
|
||||
g.Meta `path:"/event/seat/query" method:"post" tags:"event" summary:"扫码查座(免登录)"`
|
||||
EventId int64 `v:"required#活动不存在" json:"event_id"`
|
||||
@@ -51,10 +59,12 @@ type EventSeatQueryReq struct {
|
||||
|
||||
// EventSeatQueryRes 结果最小化:只有本人桌座 + 本人所在桌示意。
|
||||
//
|
||||
// Found=false 表示未查询到(双因子不匹配/未录入/未分配座位),message 为统一模糊文案,
|
||||
// Found=false 表示未查询到(不匹配/未录入/未分配座位),message 为统一模糊文案,
|
||||
// 前端据 Found 判定;找不到时所有桌座字段为空,不透出任何他人信息。
|
||||
type EventSeatQueryRes struct {
|
||||
Found bool `json:"found"`
|
||||
Found bool `json:"found"`
|
||||
// 🔴 true = 命中重名,需补手机号二次确认。此时 Found=false 且所有座位字段为空(后端不下发)。
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Message string `json:"message"`
|
||||
TableNo string `json:"table_no"`
|
||||
TableName string `json:"table_name"`
|
||||
|
||||
@@ -48,6 +48,7 @@ const (
|
||||
CodeSeatCooling = 4007 // 查座连续失败冷却中
|
||||
CodeImportConflict = 4008 // 导入存在未处理冲突
|
||||
CodeLayoutConflict = 4009 // 布局版本冲突(CAS 失败)
|
||||
CodeSeatAmbiguous = 4010 // 查座命中重名,需补手机号二次确认(**不返回任何座位信息**)
|
||||
CodePayFail = 4101 // 下单/支付参数异常
|
||||
)
|
||||
|
||||
|
||||
@@ -51,8 +51,9 @@ func AdminEventQrcode(ctx context.Context, req *v1.EventQrcodeReq) (*v1.EventQrc
|
||||
}
|
||||
token, err := wxAccessToken(ctx)
|
||||
if err != nil {
|
||||
// 把底层错误转成人话,不把原始英文/错误码直接抛给用户
|
||||
return nil, gerror.New("获取微信 access_token 失败,请稍后重试或检查 wx.appId / wx.appSecret 配置")
|
||||
// 保留底层 errcode/errmsg(gerror.Wrap 而非丢弃),否则「配置没填」与「编码不对」
|
||||
// 「IP 白名单」「appSecret 错」四种完全不同的问题在后台会显示成同一句话,无法排障。
|
||||
return nil, gerror.Wrap(err, "获取微信 access_token 失败")
|
||||
}
|
||||
|
||||
// scene 只承载 event_id(getUnlimited 的 scene 有长度上限,event_id 远不触顶)
|
||||
|
||||
@@ -3,6 +3,7 @@ package logic
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -24,7 +25,9 @@ import (
|
||||
//
|
||||
// 三条红线:
|
||||
// 1) 只查已发布活动(status=published),否则 4005;
|
||||
// 2) 姓名+手机号双因子精确匹配,失败文案统一模糊(防枚举);
|
||||
// 2) 姓名/手机号**二选一**即可发起查询(C 端体验);命中**重名**时返回 ambiguous
|
||||
// 标记且**零座位信息**,引导补手机号二次确认——绝不因为"只输了名字"就漏出别人的桌号;
|
||||
// 未命中文案统一模糊(防枚举);
|
||||
// 3) 结果最小化:只返回本人桌座 + 本人所在桌示意,绝不返回他人信息 / 名单 / 统计。
|
||||
//
|
||||
// 限流/冷却无 Redis,以 event_seat_queries 时间窗 COUNT 为准(表为权威源)。
|
||||
@@ -73,6 +76,20 @@ func seatNotFoundMessage() string {
|
||||
return "未查询到您的座位信息,请联系活动组织者确认是否已录入"
|
||||
}
|
||||
|
||||
// seatAmbiguousMessage 命中重名时的引导文案(**不透露命中了几条**,只让用户补手机号)
|
||||
func seatAmbiguousMessage() string {
|
||||
return "有多个同名宾客,请补充手机号后再查询"
|
||||
}
|
||||
|
||||
// errSeatAmbiguous 命中重名的哨兵错误:lookupSeat 返回它,调用方回 4010 且不下发座位信息。
|
||||
//
|
||||
// 🔴 用哨兵 error 而非第二个返回值,是为了让「未命中(模糊文案,正常返回体)」
|
||||
// 与「重名(需消歧,错误码)」在类型上就分得开,不会被后续维护误合并。
|
||||
var errSeatAmbiguous = gerror.NewCode(
|
||||
gcode.New(consts.CodeSeatAmbiguous, "重名需消歧", nil),
|
||||
seatAmbiguousMessage(),
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 纯函数
|
||||
// ============================================================================
|
||||
@@ -193,12 +210,26 @@ func buildSeatCells(seats []seatBrief, selfNo int) []v1.SeatCell {
|
||||
return out
|
||||
}
|
||||
|
||||
// validSeatQueryInput 查座入参校验(姓名非空≤32、手机号为 6~20 位数字)。
|
||||
// validSeatQueryInput 查座入参校验:**姓名 / 手机号二选一非空即可**(均空才拒)。
|
||||
//
|
||||
// 🔴 与旧版「双因子都必填」的差异(迭代-2026-10-09):C 端只要能定位到人即可发起查询,
|
||||
//
|
||||
// 双因子作为**加强**因子而非**前置**因子。重名的消歧由 lookupSeat 返回 ambiguous 兜住。
|
||||
func validSeatQueryInput(name, phone string) bool {
|
||||
if name == "" || len([]rune(name)) > 32 {
|
||||
hasName := name != ""
|
||||
hasPhone := phone != ""
|
||||
// 两个都空 → 拒(防无意义查询打满限流配额)
|
||||
if !hasName && !hasPhone {
|
||||
return false
|
||||
}
|
||||
return validPhone(phone)
|
||||
if hasName && len([]rune(name)) > 32 {
|
||||
return false
|
||||
}
|
||||
// 填了手机号就必须合法(没填不算错)
|
||||
if hasPhone && !validPhone(phone) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -241,14 +272,37 @@ func EventSeatQuery(ctx context.Context, req *v1.EventSeatQueryReq) (*v1.EventSe
|
||||
|
||||
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, "请填写正确的姓名与手机号")
|
||||
// 🔴 三种失败原因要给**各自对应**的文案(迭代-2026-10-09):
|
||||
// 统一回「请填写姓名或手机号」会在「姓名已填、只有手机号格式错」时误导用户
|
||||
// 去反复填姓名(实测截图即此问题)。顺序按「最具体 → 最宽泛」,
|
||||
// 与 validSeatQueryInput 内部的判定顺序一致,便于逐条对应。
|
||||
switch {
|
||||
case phone != "" && !validPhone(phone):
|
||||
// 手机号填了但不合法 —— 明确指向手机号,避免用户去改已填对的姓名
|
||||
return nil, gerror.NewCode(gcode.CodeValidationFailed, "手机号格式不正确,请填写 11 位手机号")
|
||||
case name != "" && len([]rune(name)) > 32:
|
||||
return nil, gerror.NewCode(gcode.CodeValidationFailed, "姓名过长,请检查后重新输入")
|
||||
default:
|
||||
// 两项都空
|
||||
return nil, gerror.NewCode(gcode.CodeValidationFailed, "请填写姓名或手机号")
|
||||
}
|
||||
}
|
||||
|
||||
lookup, err := lookupSeat(ctx, req.EventId, name, phone)
|
||||
if err != nil {
|
||||
// 🔴 命中重名:**返回 ambiguous 标记 + 零座位信息**(不是 404/模糊文案)。
|
||||
// 前端据此把手机号输入框升为必填做二次查询。
|
||||
if errors.Is(err, errSeatAmbiguous) || gerror.Code(err).Code() == consts.CodeSeatAmbiguous {
|
||||
writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0)
|
||||
return &v1.EventSeatQueryRes{
|
||||
Found: false,
|
||||
Ambiguous: true,
|
||||
Message: seatAmbiguousMessage(),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if lookup == nil {
|
||||
@@ -284,20 +338,53 @@ func publicEventRow(ctx context.Context, eventId int64) (*entity.AnnualEvents, e
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// lookupSeat 双因子精确匹配:本人须在企业员工库、且在该活动候场名单、且已绑定座位。
|
||||
// lookupSeat 查座位:name / phone **二选一或都填**均可查询。
|
||||
//
|
||||
// 匹配语义:
|
||||
// - 两者都填 → 双因子精确匹配(等价于旧行为);
|
||||
// - 只给一个 → 单因子匹配;命中**多行**(重名)时返回 ambiguous=nil 行语义,
|
||||
// 由调用方回 4010 并**不下发任何座位信息**(见 seatAmbiguous 哨兵)。
|
||||
//
|
||||
// 返回 (nil, nil) 表示未命中(前端展示统一模糊文案);返回 nil lookup + ambiguousErr
|
||||
// 表示命中重名、需补手机号消歧。
|
||||
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 " +
|
||||
// 基础 JOIN:本人须在企业员工库、且在该活动候场名单、且已绑定座位。
|
||||
// ⚠️ 这里**不**用 DISTINCT:同名同人的多行正是「重名」的判据。
|
||||
const baseSQL = "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)
|
||||
"JOIN event_seats s ON s.employee_id = e.id AND s.event_id = ? "
|
||||
|
||||
var (
|
||||
sql string
|
||||
args []any
|
||||
)
|
||||
switch {
|
||||
case name != "" && phone != "":
|
||||
sql = baseSQL + "WHERE e.name = ? AND e.phone = ? LIMIT 2"
|
||||
args = []any{eventId, eventId, name, phone}
|
||||
case name != "":
|
||||
// 只按姓名:LIMIT 2 足够判定「是否重名」(0 行=未命中,1 行=唯一,≥2 行=重名)
|
||||
sql = baseSQL + "WHERE e.name = ? LIMIT 2"
|
||||
args = []any{eventId, eventId, name}
|
||||
default:
|
||||
// 只按手机号:手机号在本企业内唯一,命中即本人
|
||||
sql = baseSQL + "WHERE e.phone = ? LIMIT 2"
|
||||
args = []any{eventId, eventId, phone}
|
||||
}
|
||||
|
||||
records, err := g.DB().GetAll(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 🔴 重名:不返回任何座位信息,交由调用方回 4010 引导补手机号
|
||||
if len(records) > 1 {
|
||||
return nil, errSeatAmbiguous
|
||||
}
|
||||
|
||||
tableId := records[0]["table_id"].Int64()
|
||||
seatNo := records[0]["seat_no"].Int()
|
||||
|
||||
|
||||
@@ -896,7 +896,8 @@ func AdminProjectQrcode(ctx context.Context, id int64) (*adminv1.AdminProjectQrc
|
||||
}
|
||||
token, err := wxAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.New("获取微信 access_token 失败,请稍后重试或检查 wx.appId / wx.appSecret 配置;也可把绑定码 " + p["bind_token"].String() + " 直接发给用户,在绑定页手动输入")
|
||||
// gerror.Wrap 保留底层 errcode/errmsg,便于区分「配置缺失 / 编码不对 / IP 白名单」
|
||||
return nil, gerror.Wrap(err, "获取微信 access_token 失败;也可把绑定码 "+p["bind_token"].String()+" 直接发给用户,在绑定页手动输入")
|
||||
}
|
||||
scene := "p" + strconv.FormatInt(p["id"].Int64(), 10) + p["bind_token"].String()
|
||||
body := g.Map{
|
||||
|
||||
@@ -136,7 +136,12 @@ func wxAccessToken(ctx context.Context) (string, error) {
|
||||
if accessTokenCache != "" && time.Now().Before(accessTokenExpire) {
|
||||
return accessTokenCache, nil
|
||||
}
|
||||
resp, err := g.Client().Post(ctx, xpayAPIBase+"/cgi-bin/stable_token", g.Map{
|
||||
// 🔴 ContentJson() 不可省:GoFrame 的 g.Client().Post 默认发
|
||||
// application/x-www-form-urlencoded,而微信 /cgi-bin/stable_token **只接受 JSON body**,
|
||||
// 用默认编码会被拒为 `errcode 41002 appid missing`(实测复现),
|
||||
// 于是所有依赖 access_token 的功能(活动小程序码、项目绑定码、订阅消息)全线报
|
||||
// 「获取微信 access_token 失败」。body 为 g.Map 时由 ContentJson 序列化为 JSON。
|
||||
resp, err := g.Client().ContentJson().Post(ctx, xpayAPIBase+"/cgi-bin/stable_token", g.Map{
|
||||
"grant_type": "client_credential",
|
||||
"appid": cfg.AppId,
|
||||
"secret": cfg.Secret,
|
||||
|
||||
Reference in New Issue
Block a user