小程序一些功能更新

This commit is contained in:
李琦
2026-10-09 16:49:36 +08:00
parent 6f51d99a78
commit f0e833b84e
7 changed files with 128 additions and 18 deletions

View File

@@ -48,6 +48,7 @@ const (
CodeSeatCooling = 4007 // 查座连续失败冷却中
CodeImportConflict = 4008 // 导入存在未处理冲突
CodeLayoutConflict = 4009 // 布局版本冲突(CAS 失败)
CodeSeatAmbiguous = 4010 // 查座命中重名,需补手机号二次确认(**不返回任何座位信息**)
CodePayFail = 4101 // 下单/支付参数异常
)

View File

@@ -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 远不触顶)

View File

@@ -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()

View File

@@ -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{

View File

@@ -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,