package logic import ( "context" "encoding/json" "strconv" "strings" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gfile" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" ) // ============================================================================ // 年会域:活动小程序码(wxacode.getUnlimited) // 依据:PRD-02 R7-11/R7-24;架构 §5.2 A18、§8.3。 // // 约定: // - scene **只承载 event_id**(不含任何个人信息); // - access_token 复用 xpay.go 的 wxAccessToken(),不重造; // - 未配置 wx.appId / wx.appSecret → 返回明确中文提示,不报错不 panic。 // ============================================================================ // qrCodePage 小程序码落地页(扫码后进入的查座页分包页面)。 // 属需环境化的集成参数:上线前需确认该页面已在现网发布。 const qrCodePage = "pkg-event/query" // QrCodeDir 码图落盘目录(相对服务运行目录)。 // // ⚠️ 导出给 cmd 包使用,不要改回私有常量:该目录此前只在「生成码图」时惰性创建 // (见 AdminEventQrcode 里的 gfile.Mkdir),而 cmd.go 的静态路由 AddStaticPath // 要求目录**在注册时必须已存在**,否则直接 FATAL 退出且不降级 —— // 于是出现「服务起不来 → 生不了码图 → 目录建不出来」的先有鸡先有蛋死锁。 // 现由 cmd.go 启动时预创建(gfile.Mkdir = MkdirAll,已存在则返回 nil)。 const QrCodeDir = "manifest/qrcode" // AdminEventQrcode 生成活动小程序码:调用微信 getUnlimited → 落盘 → 记录 code_url。 func AdminEventQrcode(ctx context.Context, req *v1.EventQrcodeReq) (*v1.EventQrcodeRes, error) { event, err := eventRow(ctx, req.EventId) if err != nil { return nil, err } cfg := xpayConfig(ctx) if cfg.AppId == "" || cfg.Secret == "" { return nil, gerror.New("未配置 wx.appId / wx.appSecret,无法生成小程序码;请在该环境 config.yaml 补全后重试") } token, err := wxAccessToken(ctx) if err != nil { // 保留底层 errcode/errmsg(gerror.Wrap 而非丢弃),否则「配置没填」与「编码不对」 // 「IP 白名单」「appSecret 错」四种完全不同的问题在后台会显示成同一句话,无法排障。 return nil, gerror.Wrap(err, "获取微信 access_token 失败") } // scene 只承载 event_id(getUnlimited 的 scene 有长度上限,event_id 远不触顶) scene := strconv.FormatInt(event.Id, 10) body := g.Map{ "scene": scene, "page": qrCodePage, "check_path": false, // 联调期页面可能未发布,避免因校验路径失败 "env_version": "release", } resp, err := g.Client().ContentJson().Post(ctx, xpayAPIBase+"/wxa/getwxacodeunlimit?access_token="+token, body) if err != nil { return nil, gerror.Newf("调用 wxacode.getUnlimited 失败: %v", err) } defer resp.Close() data := resp.ReadAll() if len(data) == 0 { return nil, gerror.New("生成小程序码失败:微信返回空响应") } // 成功返回 PNG 二进制;失败返回 JSON(含 errcode/errmsg) if isJsonBytes(data) { var we struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } _ = json.Unmarshal(data, &we) return nil, gerror.Newf("生成小程序码失败:%d %s", we.ErrCode, we.ErrMsg) } // 🔴 落「图片库」而不是 qrcode 私有目录(迭代-2026-10-09):用户要求「小程序码能上传到 // 图片库、并返回上传后的地址」。图片库 = manifest/static/(与头像/封面/附件同一套, // 由 /static 路由对外),落进去后台「附件管理」能统一看到/管理这些码图。 relPath, absURL, err := saveQrcodeImage(ctx, "event_"+scene+".png", data) if err != nil { return nil, err } // 对外返回**绝对 URL**(浏览器/后台可直接打开或下载)。 // 库内仍只存相对路径(/static/...),域名由 absoluteURL 按 settings.site.base_url 统一拼 —— // 与头像/封面保持同一套约定,换域名不用改数据。 if _, err = g.Model(consts.TableAnnualEvents).Where("id", event.Id).Data(g.Map{ "code_url": relPath, "updated_at": gtime.Now(), }).Update(); err != nil { return nil, err } return &v1.EventQrcodeRes{CodeUrl: absURL}, nil } // saveQrcodeImage 把小程序码二进制落进「图片库」并登记附件表。 // // 🔴 为什么落图片库而不是 qrcode 私有目录(迭代-2026-10-09):用户要求码图「能上传到图片库、 // 返回上传后的地址」。图片库 = manifest/static/(与头像/封面/附件同一套,由 /static 路由对外), // 落进去后台「附件管理」能统一看到并管理这些码图。 // //去重:storeStatic 内部按内容 md5 命名 → 同一张码重复生成不会在图片库里堆垃圾 // (微信对同一 scene/page 返回的码图是稳定字节,md5 一致即覆盖同一文件)。 // // 返回:库内相对路径(/static/...)+ 对外绝对 URL。**库内一律存相对路径**,域名只在 // absoluteURL 里出现(与头像/封面同一约定),换部署域名时不用改数据。 func saveQrcodeImage(ctx context.Context, fileName string, data []byte) (relPath string, absURL string, err error) { // 兜底目录:/qrcode 静态路由在 cmd.go 注册时要求目录已存在(AddStaticPath 目录不存在会 FATAL)。 // 这里继续保证它存在,避免 cmd.go 之外的调用路径(如单测)漏建。 if err = gfile.Mkdir(QrCodeDir); err != nil { g.Log().Errorf(ctx, "[qrcode] 创建码图目录失败 dir=%s err=%v", QrCodeDir, err) } relPath, err = storeStatic(ctx, consts.AttachmentSceneQrcode, fileName, data) if err != nil { return "", "", err } recordAttachment(ctx, consts.AttachmentSceneQrcode, fileName, relPath, data, "admin", CtxAdminId(ctx)) return relPath, absoluteURL(ctx, relPath), nil } // isJsonBytes 判断响应是否为 JSON(微信失败时返回 JSON,成功返回 PNG)。 func isJsonBytes(b []byte) bool { t := strings.TrimSpace(string(b)) return strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") }