diff --git a/.gitignore b/.gitignore index 0855458..67d8dee 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ bin/ gf main main.exe +# go build -o 直接落在仓库根的二进制(无扩展名,如 nl-qtx-api) +/nl-* +/tool-api +*.exe output/ manifest/output/ # 运行时日志(由 manifest/config/config.yaml 的 logger.path 指定) diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 6a775bd..80fa4f7 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -507,6 +507,7 @@ const ( AttachmentSceneAvatar = "avatar" // 用户头像(小程序端上传) AttachmentSceneCover = "cover" // 服务封面(管理端上传) AttachmentSceneAttachment = "attachment" // 通用附件(管理端上传) + AttachmentSceneQrcode = "qrcode" // 小程序码(活动码 / 项目绑定码,迭代-2026-10-09) ) // 站点 settings 键 diff --git a/internal/logic/event_code.go b/internal/logic/event_code.go index ac173fe..985f691 100644 --- a/internal/logic/event_code.go +++ b/internal/logic/event_code.go @@ -83,24 +83,49 @@ func AdminEventQrcode(ctx context.Context, req *v1.EventQrcodeReq) (*v1.EventQrc return nil, gerror.Newf("生成小程序码失败:%d %s", we.ErrCode, we.ErrMsg) } - if err = gfile.Mkdir(QrCodeDir); err != nil { - return nil, gerror.Newf("创建码图目录失败: %v", err) - } - fileName := "event_" + scene + "_" + gtime.Now().Format("YmdHis") + ".png" - path := gfile.Join(QrCodeDir, fileName) - if err = gfile.PutBytes(path, data); err != nil { - return nil, gerror.Newf("写入码图失败: %v", err) + // 🔴 落「图片库」而不是 qrcode 私有目录(迭代-2026-10-09):用户要求「小程序码能上传到 + // 图片库、并返回上传后的地址」。图片库 = manifest/static/(与头像/封面/附件同一套, + // 由 /static 路由对外),落进去后台「附件管理」能统一看到/管理这些码图。 + relPath, absURL, err := saveQrcodeImage(ctx, "event_"+scene+".png", data) + if err != nil { + return nil, err } - // 对外可访问地址(由 cmd.go 的静态路由 /qrcode 提供) - codeUrl := "/qrcode/" + fileName + // 对外返回**绝对 URL**(浏览器/后台可直接打开或下载)。 + // 库内仍只存相对路径(/static/...),域名由 absoluteURL 按 settings.site.base_url 统一拼 —— + // 与头像/封面保持同一套约定,换域名不用改数据。 if _, err = g.Model(consts.TableAnnualEvents).Where("id", event.Id).Data(g.Map{ - "code_url": codeUrl, + "code_url": relPath, "updated_at": gtime.Now(), }).Update(); err != nil { return nil, err } - return &v1.EventQrcodeRes{CodeUrl: codeUrl}, nil + 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)。 diff --git a/internal/logic/event_code_qrcode_test.go b/internal/logic/event_code_qrcode_test.go new file mode 100644 index 0000000..97b7cfc --- /dev/null +++ b/internal/logic/event_code_qrcode_test.go @@ -0,0 +1,101 @@ +package logic + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gogf/gf/v2/frame/g" +) + +// TestSaveQrcodeImage 验证「小程序码落图片库」契约(迭代-2026-10-09)。 +// +// 关注四点: +// 1. 落盘位置在图片库 manifest/static/qrcode//.png(不是 qrcode 私有目录); +// 2. 返回的 relPath 以 /static/ 开头(库内只存相对路径); +// 3. 同一份字节重复调用**不新增文件**(md5 命名天然去重,不会在图片库堆垃圾); +// 4. 无 site.base_url 且无请求上下文时,absURL 回退为相对路径本身(本地联调零配置)。 +func TestSaveQrcodeImage(t *testing.T) { + ctx := context.Background() + // storeStatic 会写 manifest/static/...,测试必须在仓库根跑(go test 的 CWD 是包目录) + // 故这里显式切到仓库根。 + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd 失败:%v", err) + } + if err = os.Chdir("../.."); err != nil { + t.Fatalf("切到仓库根失败:%v", err) + } + defer func() { _ = os.Chdir(wd) }() + + // 造一张最小合法 PNG(1x1),只为验证落盘与命名,不校验图片内容 + data := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + } + relPath, absURL, err := saveQrcodeImage(ctx, "event_probe.png", data) + if err != nil { + t.Fatalf("saveQrcodeImage 失败:%v", err) + } + + if !strings.HasPrefix(relPath, staticURLPrefix+"/") { + t.Fatalf("relPath 必须以 %s/ 开头(库内只存相对路径),实际=%q", staticURLPrefix, relPath) + } + // 落在图片库的 qrcode 子目录下(storeStatic 里 dirName = scene = "qrcode") + if !strings.Contains(relPath, "/qrcode/") { + t.Fatalf("relPath 应落在图片库 qrcode 子目录,实际=%q", relPath) + } + + // 落盘文件必须真实存在 + disk := filepath.Join(StaticDir, filepath.FromSlash(strings.TrimPrefix(relPath, staticURLPrefix+"/"))) + if _, err = os.Stat(disk); err != nil { + t.Fatalf("码图未落盘:%s(%v)", disk, err) + } + + // 去重:同一份字节再调一次,文件数不应增加 + before := countQrcodeFiles(t) + if _, _, err = saveQrcodeImage(ctx, "event_probe.png", data); err != nil { + t.Fatalf("第二次 saveQrcodeImage 失败:%v", err) + } + after := countQrcodeFiles(t) + if after != before { + t.Fatalf("重复生成应复用同一文件(md5 去重),文件数 %d → %d", before, after) + } + + // 无 site.base_url + 无请求上下文 → absURL 回退为相对路径本身 + if absURL != relPath { + t.Logf("absURL=%q(未配置 base_url 时回退相对路径,符合预期)", absURL) + } + if !strings.HasPrefix(absURL, "http://") && !strings.HasPrefix(absURL, "https://") && absURL != relPath { + t.Fatalf("absURL 既不是绝对 URL 也不是 relPath:%q", absURL) + } + + // 附件表登记(recordAttachment 失败不阻断,故只告警不 Fatal;但成功时应能查到) + if n, e := g.Model("attachments").Where("file_path", relPath).WhereNull("deleted_at").Count(); e == nil { + t.Logf("附件表登记条数=%d(0 表示未登记,可能库未连通,不影响落盘结论)", n) + } else { + t.Logf("附件表查询失败(可能未连库):%v", e) + } + + _ = os.Remove(disk) + t.Logf("relPath=%s absURL=%s disk=%s", relPath, absURL, disk) +} + +// countQrcodeFiles 统计图片库 qrcode 子目录下的文件数。 +func countQrcodeFiles(t *testing.T) int { + t.Helper() + dir := filepath.Join(StaticDir, "qrcode") + var n int + _ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + n++ + } + return nil + }) + return n +} \ No newline at end of file diff --git a/internal/logic/event_layout.go b/internal/logic/event_layout.go index ad344ca..3b4e0ae 100644 --- a/internal/logic/event_layout.go +++ b/internal/logic/event_layout.go @@ -241,7 +241,9 @@ func AdminEventList(ctx context.Context, req *v1.EventListReq) (*v1.EventListRes EventTime: formatGTime(event.EventTime), Status: event.Status, LayoutVersion: event.LayoutVersion, - CodeUrl: event.CodeUrl, + // 库内存相对路径(迭代-2026-10-09),读出侧统一拼绝对 URL: + // 后台直接把它塞进 / ,相对路径会 404 + CodeUrl: absoluteURL(ctx, event.CodeUrl), TableCount: int64(tableCount), }) } diff --git a/internal/logic/project.go b/internal/logic/project.go index a6424cd..296ae82 100644 --- a/internal/logic/project.go +++ b/internal/logic/project.go @@ -11,7 +11,6 @@ import ( "github.com/gogf/gf/v2/database/gdb" "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" adminv1 "tool-api/api/admin/v1" @@ -473,7 +472,9 @@ func AdminProjectList(ctx context.Context, req *adminv1.AdminProjectListReq) (*a Status: r["status"].Int(), StatusText: consts.ProjectStatusNameOf(r["status"].Int()), BindToken: r["bind_token"].String(), - CodeUrl: r["code_url"].String(), + // 库内存相对路径(迭代-2026-10-09),读出侧统一拼绝对 URL, + // 否则后台 / 用相对路径会 404 + CodeUrl: absoluteURL(ctx, r["code_url"].String()), CreatedAt: timeStr(r["created_at"].GTime()), UpdatedAt: timeStr(r["updated_at"].GTime()), } @@ -790,7 +791,8 @@ func AdminProjectDetail(ctx context.Context, id int64) (*adminv1.AdminProjectDet Status: p["status"].Int(), StatusText: consts.ProjectStatusNameOf(p["status"].Int()), BindToken: p["bind_token"].String(), - CodeUrl: p["code_url"].String(), + // 库内存相对路径,读出侧拼绝对 URL(同上) + CodeUrl: absoluteURL(ctx, p["code_url"].String()), CreatedAt: timeStr(p["created_at"].GTime()), UpdatedAt: timeStr(p["updated_at"].GTime()), } @@ -923,22 +925,19 @@ func AdminProjectQrcode(ctx context.Context, id int64) (*adminv1.AdminProjectQrc _ = json.Unmarshal(data, &we) return nil, gerror.Newf("生成小程序码失败:%d %s(页面未发布时请改用正式环境或令牌绑定)", we.ErrCode, we.ErrMsg) } - if err = gfile.Mkdir(QrCodeDir); err != nil { - return nil, gerror.Newf("创建码图目录失败: %v", err) + // 🔴 与活动码共用落盘设施(saveQrcodeImage,迭代-2026-10-09):落「图片库」manifest/static/ + // 并返回绝对 URL,库内只存相对路径。 + relPath, absURL, err := saveQrcodeImage(ctx, "project_"+strconv.FormatInt(p["id"].Int64(), 10)+".png", data) + if err != nil { + return nil, err } - fileName := "project_" + strconv.FormatInt(p["id"].Int64(), 10) + "_" + gtime.Now().Format("YmdHis") + ".png" - path := gfile.Join(QrCodeDir, fileName) - if err = gfile.PutBytes(path, data); err != nil { - return nil, gerror.Newf("写入码图失败: %v", err) - } - codeUrl := "/qrcode/" + fileName if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{ - "code_url": codeUrl, + "code_url": relPath, "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } - return &adminv1.AdminProjectQrcodeRes{CodeUrl: codeUrl}, nil + return &adminv1.AdminProjectQrcodeRes{CodeUrl: absURL}, nil } // AdminProjectStageSave 新增/编辑阶段;状态变更推站内信。