@@ -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 n ame = = "" || len ( [ ] rune ( name ) ) > 32 {
hasN ame : = 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 ( )