From f0e833b84e9b0daedfc606b0cc0cf5f396a4c341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 9 Oct 2026 16:49:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/user/v1/event.go | 16 ++++- internal/consts/consts.go | 1 + internal/logic/event_code.go | 5 +- internal/logic/event_query.go | 109 ++++++++++++++++++++++++++++++---- internal/logic/project.go | 3 +- internal/logic/xpay.go | 7 ++- 打包 | 5 ++ 7 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 打包 diff --git a/api/user/v1/event.go b/api/user/v1/event.go index b985fa4..be75710 100644 --- a/api/user/v1/event.go +++ b/api/user/v1/event.go @@ -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"` diff --git a/internal/consts/consts.go b/internal/consts/consts.go index bd3e022..6a775bd 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -48,6 +48,7 @@ const ( CodeSeatCooling = 4007 // 查座连续失败冷却中 CodeImportConflict = 4008 // 导入存在未处理冲突 CodeLayoutConflict = 4009 // 布局版本冲突(CAS 失败) + CodeSeatAmbiguous = 4010 // 查座命中重名,需补手机号二次确认(**不返回任何座位信息**) CodePayFail = 4101 // 下单/支付参数异常 ) diff --git a/internal/logic/event_code.go b/internal/logic/event_code.go index f100616..ac173fe 100644 --- a/internal/logic/event_code.go +++ b/internal/logic/event_code.go @@ -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 远不触顶) diff --git a/internal/logic/event_query.go b/internal/logic/event_query.go index 7d41771..cca3218 100644 --- a/internal/logic/event_query.go +++ b/internal/logic/event_query.go @@ -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() diff --git a/internal/logic/project.go b/internal/logic/project.go index 47caa68..a6424cd 100644 --- a/internal/logic/project.go +++ b/internal/logic/project.go @@ -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{ diff --git a/internal/logic/xpay.go b/internal/logic/xpay.go index ddbd257..e34614a 100644 --- a/internal/logic/xpay.go +++ b/internal/logic/xpay.go @@ -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, diff --git a/打包 b/打包 new file mode 100644 index 0000000..95f4955 --- /dev/null +++ b/打包 @@ -0,0 +1,5 @@ +$env:GOOS="linux" + +$env:GOARCH="amd64" + +go build -o nl-qtx-api main.go \ No newline at end of file