feat:增加分享页下载

This commit is contained in:
zheng020 2026-07-09 15:49:06 +08:00
parent 5625f6ac28
commit fbf3e6c63c
14 changed files with 2972 additions and 77 deletions

View File

@ -0,0 +1,93 @@
package controller
import (
"context"
"github.com/gin-gonic/gin"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/gateway/service"
)
// DownloadUrlResponse 下载页公开接口响应
type DownloadUrlResponse struct {
URL string `json:"url"`
Version string `json:"version"`
Type string `json:"type"`
}
// AppDownloadSvc AppDownloadController 依赖的 service 接口
type AppDownloadSvc interface {
GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error)
SyncVersion(ctx context.Context, req *service.SyncVersionRequest) error
}
// AppDownloadController App下载页控制器
// 注意:不走 Dubbo RPC不依赖 Dubbo client因此构造函数无需返回 error
type AppDownloadController struct {
svc AppDownloadSvc
}
// NewAppDownloadController 构造函数
func NewAppDownloadController(svc AppDownloadSvc) *AppDownloadController {
return &AppDownloadController{svc: svc}
}
// GetDownloadUrls GET /api/v1/app/download-urls
// @Summary 获取 App 下载地址
// @Description 返回 Android/iOS 最新 native_app 下载地址,供 HTML 分享页调用
// @Tags App下载页
// @Produce json
// @Success 200 {object} response.Response{data=map[string]DownloadUrlResponse}
// @Router /api/v1/app/download-urls [get]
func (ctrl *AppDownloadController) GetDownloadUrls(c *gin.Context) {
ctx := c.Request.Context()
configs, err := ctrl.svc.GetAllNativeApp(ctx)
if err != nil {
response.InternalError(c, "获取下载地址失败")
return
}
// 组装响应map[platform] -> {url, version, type}
data := make(map[string]interface{})
for _, cfg := range configs {
data[cfg.Platform] = &DownloadUrlResponse{
URL: cfg.DownloadURL,
Version: cfg.Version,
Type: cfg.Type,
}
}
// 确保 android/ios 键始终存在
for _, p := range []string{"android", "ios"} {
if _, ok := data[p]; !ok {
data[p] = nil
}
}
response.Success(c, data)
}
// SyncVersion POST /api/v1/admin/app/versions/sync
// @Summary 同步 App 下载地址
// @Description 接收 uniCloud 云函数推送的最新版本下载地址(内部接口,走 Nginx IP 白名单)
// @Tags Admin
// @Accept json
// @Produce json
// @Param body body service.SyncVersionRequest true "版本信息"
// @Success 200 {object} response.Response
// @Router /api/v1/admin/app/versions/sync [post]
func (ctrl *AppDownloadController) SyncVersion(c *gin.Context) {
var req service.SyncVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
ctx := c.Request.Context()
if err := ctrl.svc.SyncVersion(ctx, &req); err != nil {
response.InternalError(c, "同步版本信息失败")
return
}
response.Success(c, nil)
}

View File

@ -0,0 +1,169 @@
package controller
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/gateway/service"
)
// fakeAppDownloadSvc 是 AppDownloadSvc 的 in-memory fake
type fakeAppDownloadSvc struct {
configs []models.AppDownloadConfig
getAllErr error
syncErr error
syncCalled bool
lastSyncReq *service.SyncVersionRequest
}
func (f *fakeAppDownloadSvc) GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error) {
if f.getAllErr != nil {
return nil, f.getAllErr
}
return f.configs, nil
}
func (f *fakeAppDownloadSvc) SyncVersion(ctx context.Context, req *service.SyncVersionRequest) error {
f.syncCalled = true
f.lastSyncReq = req
return f.syncErr
}
func setupTestRouter(ctrl *AppDownloadController) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/app/download-urls", ctrl.GetDownloadUrls)
r.POST("/api/v1/admin/app/versions/sync", ctrl.SyncVersion)
return r
}
// ========== GET /api/v1/app/download-urls ==========
func TestGetDownloadUrls_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{
configs: []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://app.apk", Version: "1.0.5"},
{Platform: "ios", Type: "native_app", DownloadURL: "https://apps.apple.com/...", Version: "1.0.5"},
},
}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, float64(0), resp["code"])
data := resp["data"].(map[string]interface{})
assert.NotNil(t, data["android"])
assert.NotNil(t, data["ios"])
}
func TestGetDownloadUrls_EmptyData(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{configs: []models.AppDownloadConfig{}}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, float64(0), resp["code"])
data := resp["data"].(map[string]interface{})
assert.Nil(t, data["android"])
assert.Nil(t, data["ios"])
}
func TestGetDownloadUrls_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{getAllErr: errors.New("db down")}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 500, w.Code)
}
// ========== POST /api/v1/admin/app/versions/sync ==========
func TestSyncVersion_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/app/versions/sync",
strings.NewReader(`{"android": "not_an_object"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code)
}
func TestSyncVersion_MissingRequiredField(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
// 缺少必填字段 url
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/app/versions/sync",
strings.NewReader(`{"android": {"version": "1.0.5", "type": "native_app"}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code)
}
func TestSyncVersion_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
fakeSvc := &fakeAppDownloadSvc{}
ctrl := NewAppDownloadController(fakeSvc)
router := setupTestRouter(ctrl)
body := `{
"android": {"url": "https://app.apk", "version": "1.0.5", "type": "native_app"},
"ios": {"url": "https://apps.apple.com/...", "version": "1.0.5", "type": "native_app"}
}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/app/versions/sync",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.True(t, fakeSvc.syncCalled)
assert.NotNil(t, fakeSvc.lastSyncReq.Android)
assert.NotNil(t, fakeSvc.lastSyncReq.IOS)
}

View File

@ -62,6 +62,8 @@ require (
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
@ -108,6 +110,7 @@ require (
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
@ -125,6 +128,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polarismesh/polaris-go v1.3.0 // indirect github.com/polarismesh/polaris-go v1.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_golang v1.19.1 // indirect
@ -133,14 +137,16 @@ require (
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.52.0 // indirect github.com/quic-go/quic-go v0.52.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shirou/gopsutil/v3 v3.22.2 // indirect github.com/shirou/gopsutil/v3 v3.22.2 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/afero v1.6.0 // indirect github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.8.1 // indirect github.com/spf13/viper v1.8.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.2.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.10 // indirect github.com/tklauser/go-sysconf v0.3.10 // indirect
github.com/tklauser/numcpus v0.4.0 // indirect github.com/tklauser/numcpus v0.4.0 // indirect
@ -175,7 +181,7 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.1.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect golang.org/x/tools v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
@ -184,6 +190,11 @@ require (
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )
replace github.com/topfans/backend => ../ replace github.com/topfans/backend => ../

View File

@ -82,8 +82,10 @@ github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9Q
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94= github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94=
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o= github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o=
github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.18/go.mod h1:v8ESoHo4SyHmuB4b1tJqDHxfTGEciD+yhvOU/5s1Rfk= github.com/aliyun/alibaba-cloud-sdk-go v1.61.18/go.mod h1:v8ESoHo4SyHmuB4b1tJqDHxfTGEciD+yhvOU/5s1Rfk=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1704/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU= github.com/aliyun/alibaba-cloud-sdk-go v1.61.1704/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 h1:ie/8RxBOfKZWcrbYSJi2Z8uX8TcOlSMwPlEJh83OeOw= github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 h1:ie/8RxBOfKZWcrbYSJi2Z8uX8TcOlSMwPlEJh83OeOw=
@ -95,6 +97,7 @@ github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.7/go.mod h1:oDg1j4kFxnh
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.4.12 h1:7D8eXGotNwthZuUEgAMgBoqxmIHwfaPVwW+/04LIJSQ= github.com/aliyun/credentials-go v1.4.12 h1:7D8eXGotNwthZuUEgAMgBoqxmIHwfaPVwW+/04LIJSQ=
github.com/aliyun/credentials-go v1.4.12/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/dubbo-getty v1.4.10 h1:ZmkpHJa/qgS0evX2tTNqNCz6rClI/9Wwp7ctyMml82w= github.com/apache/dubbo-getty v1.4.10 h1:ZmkpHJa/qgS0evX2tTNqNCz6rClI/9Wwp7ctyMml82w=
github.com/apache/dubbo-getty v1.4.10/go.mod h1:V64WqLIxksEgNu5aBJBOxNIvpOZyfUJ7J/DXBlKSUoA= github.com/apache/dubbo-getty v1.4.10/go.mod h1:V64WqLIxksEgNu5aBJBOxNIvpOZyfUJ7J/DXBlKSUoA=
@ -160,6 +163,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@ -275,6 +279,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -402,6 +410,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -515,14 +524,20 @@ github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod
github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY= github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY=
github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y= github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869/go.mod h1:cJ6Cj7dQo+O6GJNiMx+Pa94qKj+TG8ONdKHgMNIyyag= github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869/go.mod h1:cJ6Cj7dQo+O6GJNiMx+Pa94qKj+TG8ONdKHgMNIyyag=
github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg=
github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@ -614,6 +629,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
@ -772,6 +789,10 @@ github.com/quic-go/quic-go v0.52.0 h1:/SlHrCRElyaU6MaEPKqKr9z83sBg2v4FLLvWM+Z47p
github.com/quic-go/quic-go v0.52.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= github.com/quic-go/quic-go v0.52.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@ -814,6 +835,7 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
@ -1006,7 +1028,11 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -1052,6 +1078,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -1110,7 +1139,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -1140,6 +1173,9 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -1232,14 +1268,23 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1253,7 +1298,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1265,6 +1313,7 @@ golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -1330,6 +1379,8 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -1520,7 +1571,11 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@ -1530,6 +1585,14 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,45 @@
package repository
import (
"context"
"github.com/topfans/backend/pkg/models"
"gorm.io/gorm"
)
// AppDownloadRepository 下载配置数据访问层
type AppDownloadRepository struct {
db *gorm.DB
}
// NewAppDownloadRepository 构造函数
func NewAppDownloadRepository(db *gorm.DB) *AppDownloadRepository {
return &AppDownloadRepository{db: db}
}
// FindByType 按包类型查询(用于公开接口,只返回 native_app
func (r *AppDownloadRepository) FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error) {
var configs []models.AppDownloadConfig
err := r.db.WithContext(ctx).
Where("type = ?", pkgType).
Find(&configs).Error
return configs, err
}
// UpsertAll 批量 upsertplatform + type 唯一)
func (r *AppDownloadRepository) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, cfg := range configs {
if err := tx.Where("platform = ? AND type = ?", cfg.Platform, cfg.Type).
Assign(map[string]interface{}{
"download_url": cfg.DownloadURL,
"version": cfg.Version,
"updated_at": cfg.UpdatedAt,
}).
FirstOrCreate(&cfg).Error; err != nil {
return err
}
}
return nil
})
}

View File

@ -0,0 +1,106 @@
package repository
import (
"context"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
err = db.AutoMigrate(&models.AppDownloadConfig{})
assert.NoError(t, err)
return db
}
func TestFindByType_NativeApp(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
// seed: 一条 native_app + 一条 wgt
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "native_app", DownloadURL: "https://cdn.example.com/app.apk", Version: "1.0.5",
})
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "wgt", DownloadURL: "https://cdn.example.com/wgt.wgt", Version: "1.0.5",
})
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeNativeApp)
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "native_app", configs[0].Type)
}
func TestFindByType_Wgt(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "wgt", DownloadURL: "https://cdn.example.com/wgt.wgt", Version: "1.0.5",
})
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeWgt)
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "wgt", configs[0].Type)
}
func TestUpsertAll_Insert(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
err := repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v1.apk", Version: "1.0.0"},
})
assert.NoError(t, err)
var count int64
db.Model(&models.AppDownloadConfig{}).Count(&count)
assert.Equal(t, int64(1), count)
var cfg models.AppDownloadConfig
db.First(&cfg)
assert.Equal(t, "https://v1.apk", cfg.DownloadURL)
}
func TestUpsertAll_Update(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
// 首次插入
err := repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v1.apk", Version: "1.0.0"},
})
assert.NoError(t, err)
// 更新同一条
err = repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v2.apk", Version: "2.0.0"},
})
assert.NoError(t, err)
// 验证只有 1 条且已更新
var count int64
db.Model(&models.AppDownloadConfig{}).Count(&count)
assert.Equal(t, int64(1), count)
var cfg models.AppDownloadConfig
db.First(&cfg)
assert.Equal(t, "https://v2.apk", cfg.DownloadURL)
assert.Equal(t, "2.0.0", cfg.Version)
}
func TestFindByType_Empty(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeNativeApp)
assert.NoError(t, err)
assert.Len(t, configs, 0)
}

View File

@ -11,7 +11,9 @@ import (
"github.com/topfans/backend/gateway/config" "github.com/topfans/backend/gateway/config"
"github.com/topfans/backend/gateway/controller" "github.com/topfans/backend/gateway/controller"
"github.com/topfans/backend/gateway/middleware" "github.com/topfans/backend/gateway/middleware"
"github.com/topfans/backend/gateway/repository"
"github.com/topfans/backend/gateway/service" "github.com/topfans/backend/gateway/service"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/gateway/socket" "github.com/topfans/backend/gateway/socket"
pbModeration "github.com/topfans/backend/pkg/proto/moderation" pbModeration "github.com/topfans/backend/pkg/proto/moderation"
) )
@ -126,6 +128,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
modCtrl := controller.NewModerationController() modCtrl := controller.NewModerationController()
modCtrl.SetService(modSvc) modCtrl.SetService(modSvc)
// App 下载页 — gateway 直连 PG无 Dubbo RPC
appDownloadRepo := repository.NewAppDownloadRepository(database.GetDB())
appDownloadSvc := service.NewAppDownloadService(appDownloadRepo)
appDownloadCtrl := controller.NewAppDownloadController(appDownloadSvc)
// API v1 路由组 // API v1 路由组
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
@ -282,6 +289,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
admin := v1.Group("/admin") admin := v1.Group("/admin")
{ {
admin.POST("/notifications", notificationCtrl.AdminCreateNotification) // 批量发送通知 admin.POST("/notifications", notificationCtrl.AdminCreateNotification) // 批量发送通知
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // App 下载页 — 版本同步
} }
// 人像抠图(镭射卡 thinking 阶段;密钥仅服务端) // 人像抠图(镭射卡 thinking 阶段;密钥仅服务端)
@ -290,6 +298,9 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
// 人像抠图 — Dify 工作流调用内网专用JSON body { image_url, scene },无需 JWT // 人像抠图 — Dify 工作流调用内网专用JSON body { image_url, scene },无需 JWT
v1.POST("/segment/json", segmentCtrl.PortraitByURL) v1.POST("/segment/json", segmentCtrl.PortraitByURL)
// App 下载页 — HTML 分享页使用(公开,无需认证)
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
// 镭射卡 AI 生成Dify 工作流编排) // 镭射卡 AI 生成Dify 工作流编排)
laser := v1.Group("/laser") laser := v1.Group("/laser")
laser.Use(middleware.AuthMiddleware()) laser.Use(middleware.AuthMiddleware())

View File

@ -0,0 +1,71 @@
package service
import (
"context"
"time"
"github.com/topfans/backend/gateway/repository"
"github.com/topfans/backend/pkg/models"
)
// SyncVersionRequest uniCloud 同步请求体
// 注:定义在 service 包(而非 controller 包),避免 controller ↔ service 循环依赖
type SyncVersionRequest struct {
Android *PlatformVersionInfo `json:"android"`
IOS *PlatformVersionInfo `json:"ios"`
}
// PlatformVersionInfo 单个平台的版本信息
type PlatformVersionInfo struct {
URL string `json:"url" binding:"required"`
Version string `json:"version"`
Type string `json:"type" binding:"required,oneof=native_app wgt"`
}
// appDownloadRepo 下载配置仓库接口(仅暴露 service 层需要的两个方法)
type appDownloadRepo interface {
FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error)
UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error
}
// AppDownloadService App下载页业务逻辑
type AppDownloadService struct {
repo appDownloadRepo
}
// NewAppDownloadService 构造函数
func NewAppDownloadService(repo *repository.AppDownloadRepository) *AppDownloadService {
return &AppDownloadService{repo: repo}
}
// GetAllNativeApp 获取所有 native_app 类型的下载配置
func (s *AppDownloadService) GetAllNativeApp(ctx context.Context) ([]models.AppDownloadConfig, error) {
return s.repo.FindByType(ctx, models.AppPackageTypeNativeApp)
}
// SyncVersion 同步版本信息upsert
func (s *AppDownloadService) SyncVersion(ctx context.Context, req *SyncVersionRequest) error {
now := time.Now().UnixMilli()
var configs []models.AppDownloadConfig
if req.Android != nil {
configs = append(configs, models.AppDownloadConfig{
Platform: "android",
Type: req.Android.Type,
DownloadURL: req.Android.URL,
Version: req.Android.Version,
UpdatedAt: now,
})
}
if req.IOS != nil {
configs = append(configs, models.AppDownloadConfig{
Platform: "ios",
Type: req.IOS.Type,
DownloadURL: req.IOS.URL,
Version: req.IOS.Version,
UpdatedAt: now,
})
}
return s.repo.UpsertAll(ctx, configs)
}

View File

@ -0,0 +1,110 @@
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
)
// fakeRepo 是 appDownloadRepo 的 in-memory fake 实现
type fakeRepo struct {
configs []models.AppDownloadConfig
}
func (f *fakeRepo) FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error) {
var result []models.AppDownloadConfig
for _, c := range f.configs {
if c.Type == pkgType {
result = append(result, c)
}
}
return result, nil
}
func (f *fakeRepo) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
for _, incoming := range configs {
found := false
for i, existing := range f.configs {
if existing.Platform == incoming.Platform && existing.Type == incoming.Type {
f.configs[i].DownloadURL = incoming.DownloadURL
f.configs[i].Version = incoming.Version
f.configs[i].UpdatedAt = incoming.UpdatedAt
found = true
break
}
}
if !found {
f.configs = append(f.configs, incoming)
}
}
return nil
}
func TestGetAllNativeApp_FiltersWgt(t *testing.T) {
svc := &AppDownloadService{
repo: &fakeRepo{configs: []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://app.apk"},
{Platform: "android", Type: "wgt", DownloadURL: "https://wgt.wgt"},
}},
}
configs, err := svc.GetAllNativeApp(context.Background())
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "native_app", configs[0].Type)
}
func TestGetAllNativeApp_Empty(t *testing.T) {
svc := &AppDownloadService{
repo: &fakeRepo{},
}
configs, err := svc.GetAllNativeApp(context.Background())
assert.NoError(t, err)
assert.Len(t, configs, 0)
}
func TestSyncVersion_BothPlatforms(t *testing.T) {
fake := &fakeRepo{}
svc := &AppDownloadService{repo: fake}
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
Android: &PlatformVersionInfo{URL: "https://a.apk", Version: "1.0.5", Type: "native_app"},
IOS: &PlatformVersionInfo{URL: "https://apps.apple.com/...", Version: "1.0.5", Type: "native_app"},
})
assert.NoError(t, err)
assert.Len(t, fake.configs, 2)
}
func TestSyncVersion_PartialUpdate(t *testing.T) {
fake := &fakeRepo{
configs: []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://old.apk", Version: "1.0.0"},
},
}
svc := &AppDownloadService{repo: fake}
// 只更新 android不传 ios
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
Android: &PlatformVersionInfo{URL: "https://new.apk", Version: "2.0.0", Type: "native_app"},
})
assert.NoError(t, err)
assert.Len(t, fake.configs, 1) // 仍然是 1 条
assert.Equal(t, "https://new.apk", fake.configs[0].DownloadURL)
assert.Equal(t, "2.0.0", fake.configs[0].Version)
}
func TestSyncVersion_NilPlatform(t *testing.T) {
fake := &fakeRepo{}
svc := &AppDownloadService{repo: fake}
// 只传 iOSandroid 为 nil
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
IOS: &PlatformVersionInfo{URL: "https://apps.apple.com/...", Version: "1.0.5", Type: "native_app"},
})
assert.NoError(t, err)
assert.Len(t, fake.configs, 1)
assert.Equal(t, "ios", fake.configs[0].Platform)
}

View File

@ -0,0 +1,24 @@
-- App 下载页配置表
-- 存储各平台最新下载地址,由 uniCloud 云函数自动同步
-- type 区分 native_app整包安装和 wgt热更新资源包
CREATE TABLE IF NOT EXISTS public.app_download_configs (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(20) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'native_app',
download_url TEXT NOT NULL,
version VARCHAR(50),
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
CONSTRAINT uq_app_download_configs_platform_type UNIQUE (platform, type)
);
COMMENT ON TABLE public.app_download_configs IS 'App 下载页配置,存储各平台最新下载地址';
COMMENT ON COLUMN public.app_download_configs.platform IS '平台标识android / ios';
COMMENT ON COLUMN public.app_download_configs.type IS '包类型native_app整包安装/ wgt热更新资源包';
COMMENT ON COLUMN public.app_download_configs.download_url IS '下载地址或 App Store 链接';
COMMENT ON COLUMN public.app_download_configs.version IS '版本号,如 1.0.5';
-- 预留序列起始值(按项目规范)
ALTER SEQUENCE public.app_download_configs_id_seq RESTART WITH 10000;

View File

@ -0,0 +1,23 @@
package models
// AppDownloadConfig App下载页配置表模型
type AppDownloadConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id" json:"-"`
Platform string `gorm:"type:varchar(20);uniqueIndex:uq_app_download_configs_platform_type;not null;column:platform" json:"platform"`
Type string `gorm:"type:varchar(20);uniqueIndex:uq_app_download_configs_platform_type;not null;default:native_app;column:type" json:"type"`
DownloadURL string `gorm:"type:text;not null;column:download_url" json:"download_url"`
Version string `gorm:"type:varchar(50);column:version" json:"version"`
CreatedAt int64 `gorm:"not null;column:created_at" json:"created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at" json:"updated_at"`
}
// TableName 指定表名
func (AppDownloadConfig) TableName() string {
return "app_download_configs"
}
// 包类型常量
const (
AppPackageTypeNativeApp = "native_app" // 整包安装
AppPackageTypeWgt = "wgt" // 热更新资源包
)

View File

@ -1,6 +1,6 @@
# App 下载分享页 — 自动同步方案设计 # App 下载分享页 — 自动同步方案设计
> **文档状态**Draft · 2026-07-08 > **文档状态**Draft v1.1 · 2026-07-08(修订 2026-07-09
> **适用版本**topfans v1.0.5+ > **适用版本**topfans v1.0.5+
> **目标读者**:后端 / uni-admin / 前端开发 > **目标读者**:后端 / uni-admin / 前端开发
@ -310,24 +310,13 @@ gateway/
package controller package controller
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/gateway/service" "github.com/topfans/backend/gateway/service"
) )
// SyncVersionRequest uniCloud 同步请求体 // SyncVersionRequest uniCloud 同步请求体(定义在 service 包,此处引用)
type SyncVersionRequest struct { // 见 service/app_download_service.go
Android *PlatformVersionInfo `json:"android"`
IOS *PlatformVersionInfo `json:"ios"`
}
// PlatformVersionInfo 单个平台的版本信息
type PlatformVersionInfo struct {
URL string `json:"url" binding:"required"`
Version string `json:"version"`
Type string `json:"type" binding:"required,oneof=native_app wgt"`
}
// DownloadUrlResponse 下载页公开接口响应 // DownloadUrlResponse 下载页公开接口响应
type DownloadUrlResponse struct { type DownloadUrlResponse struct {
@ -337,6 +326,8 @@ type DownloadUrlResponse struct {
} }
// AppDownloadController App下载页控制器 // AppDownloadController App下载页控制器
// 注意:不走 Dubbo RPC§12.5),不依赖 Dubbo client因此构造函数无需返回 error
// (与 NewSegmentController / NewLaserGenerateController 等无 Dubbo 依赖的 controller 一致)
type AppDownloadController struct { type AppDownloadController struct {
svc *service.AppDownloadService svc *service.AppDownloadService
} }
@ -353,15 +344,18 @@ func NewAppDownloadController(svc *service.AppDownloadService) *AppDownloadContr
// (续 app_download_controller.go) // (续 app_download_controller.go)
// GetDownloadUrls GET /api/v1/app/download-urls // GetDownloadUrls GET /api/v1/app/download-urls
// @Summary 获取 App 下载地址
// @Description 返回 Android/iOS 最新 native_app 下载地址,供 HTML 分享页调用
// @Tags App下载页
// @Produce json
// @Success 200 {object} response.Response{data=map[string]DownloadUrlResponse}
// @Router /api/v1/app/download-urls [get]
// 只返回 type = native_app 的记录 // 只返回 type = native_app 的记录
func (ctrl *AppDownloadController) GetDownloadUrls(c *gin.Context) { func (ctrl *AppDownloadController) GetDownloadUrls(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
configs, err := ctrl.svc.GetAllNativeApp(ctx) configs, err := ctrl.svc.GetAllNativeApp(ctx)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ response.InternalError(c, "获取下载地址失败")
"code": 500,
"message": "internal error",
})
return return
} }
@ -381,30 +375,32 @@ func (ctrl *AppDownloadController) GetDownloadUrls(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": data}) response.Success(c, data)
} }
// SyncVersion POST /api/v1/admin/app/versions/sync // SyncVersion POST /api/v1/admin/app/versions/sync
// @Summary 同步 App 下载地址
// @Description 接收 uniCloud 云函数推送的最新版本下载地址(内部接口,走 Nginx IP 白名单)
// @Tags Admin
// @Accept json
// @Produce json
// @Param body body SyncVersionRequest true "版本信息"
// @Success 200 {object} response.Response
// @Router /api/v1/admin/app/versions/sync [post]
func (ctrl *AppDownloadController) SyncVersion(c *gin.Context) { func (ctrl *AppDownloadController) SyncVersion(c *gin.Context) {
var req SyncVersionRequest var req service.SyncVersionRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ response.BadRequest(c, "invalid request: "+err.Error())
"code": 400,
"message": "invalid request: " + err.Error(),
})
return return
} }
ctx := c.Request.Context() ctx := c.Request.Context()
if err := ctrl.svc.SyncVersion(ctx, &req); err != nil { if err := ctrl.svc.SyncVersion(ctx, &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ response.InternalError(c, "sync failed")
"code": 500,
"message": "sync failed",
})
return return
} }
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"}) response.Success(c, nil)
} }
``` ```
@ -422,6 +418,20 @@ import (
"github.com/topfans/backend/pkg/models" "github.com/topfans/backend/pkg/models"
) )
// SyncVersionRequest uniCloud 同步请求体
// 注:定义在 service 包(而非 controller 包),避免 controller ↔ service 循环依赖
type SyncVersionRequest struct {
Android *PlatformVersionInfo `json:"android"`
IOS *PlatformVersionInfo `json:"ios"`
}
// PlatformVersionInfo 单个平台的版本信息
type PlatformVersionInfo struct {
URL string `json:"url" binding:"required"`
Version string `json:"version"`
Type string `json:"type" binding:"required,oneof=native_app wgt"`
}
// AppDownloadService App下载页业务逻辑 // AppDownloadService App下载页业务逻辑
type AppDownloadService struct { type AppDownloadService struct {
repo *repository.AppDownloadRepository repo *repository.AppDownloadRepository
@ -518,34 +528,56 @@ func (r *AppDownloadRepository) UpsertAll(ctx context.Context, configs []models.
### 4.5 路由注册 ### 4.5 路由注册
`router.go``v1` 路由组中添加: `router.go``SetupRouter` 函数中,按以下方式集成。
**Step 1 — 创建 controller**(在现有 controller 初始化代码块末尾添加):
```go ```go
// ============ App 下载页接口 ============ // App 下载页 — gateway 直连 PG无 Dubbo RPC参见 §12.5
appDownloadRepo := repository.NewAppDownloadRepository(database.GetDB())
// 公开接口 — HTML 分享页使用
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
// Admin 内部接口 — uniCloud 云函数同步使用
admin := v1.Group("/admin")
{
admin.POST("/notifications", notificationCtrl.AdminCreateNotification)
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // ← 新增
}
```
### 4.6 main.go 装配(依赖注入)
`gateway/main.go` 中添加:
```go
// App 下载页 — 三层装配
appDownloadRepo := repository.NewAppDownloadRepository(db)
appDownloadSvc := service.NewAppDownloadService(appDownloadRepo) appDownloadSvc := service.NewAppDownloadService(appDownloadRepo)
appDownloadCtrl := controller.NewAppDownloadController(appDownloadSvc) appDownloadCtrl := controller.NewAppDownloadController(appDownloadSvc)
``` ```
> 如果 gateway 目前没有直连 PG`db *gorm.DB`),需要先初始化一个只读连接。详见 §12.5。 > `database.GetDB()` 是项目已有的全局 PG 连接获取函数(`backend/pkg/database/database.go`),无需修改 `SetupRouter` 函数签名。
**Step 2 — 注册公开路由**(在 `v1` 路由组内,与其他公开路由并列添加):
```go
// App 下载页 — HTML 分享页使用(公开,无需认证)
v1.GET("/app/download-urls", appDownloadCtrl.GetDownloadUrls)
```
**Step 3 — 注册 Admin 内部路由**(在现有 `admin := v1.Group("/admin")` 块内添加一行):
```go
admin := v1.Group("/admin")
{
admin.POST("/notifications", notificationCtrl.AdminCreateNotification) // 已有
admin.POST("/app/versions/sync", appDownloadCtrl.SyncVersion) // ← 新增
}
```
> **关键**:不要新建 admin group在现有块内追加即可。现有 admin group 已在 `v1` 下、无鉴权中间件,依赖 Nginx IP 白名单保护。
### 4.6 依赖注入(装配)
三层装配在 `SetupRouter` 内部完成(与项目现有 controller 创建模式一致):
```
database.GetDB() ← 全局 PG 连接
repository.NewAppDownloadRepository ← 数据层
service.NewAppDownloadService ← 业务层
controller.NewAppDownloadController ← 控制器(注册路由)
```
> 不需要在 `main.go` 中额外装配。`SetupRouter` 已接收所有 Dubbo client 参数并在内部创建 controller本功能的 controller 也在此创建,无需修改 `main.go`
--- ---
@ -1027,11 +1059,14 @@ backend/
│ └── app_download_config.go # GORM model + 包类型常量 │ └── app_download_config.go # GORM model + 包类型常量
├── gateway/ ├── gateway/
│ ├── controller/ │ ├── controller/
│ │ └── app_download_controller.go # handler: DTO + 两个接口 │ │ ├── app_download_controller.go # handler: DTO + 两个接口(含 Swagger 注解)
│ │ └── app_download_controller_test.go # controller 集成测试
│ ├── service/ │ ├── service/
│ │ └── app_download_service.go # 业务层: sync + query │ │ ├── app_download_service.go # 业务层: sync + query含请求 DTO
│ │ └── app_download_service_test.go # service 单元测试
│ └── repository/ │ └── repository/
│ └── app_download_repository.go # 数据层: upsert + find │ ├── app_download_repository.go # 数据层: upsert + find
│ └── app_download_repository_test.go # repository 单元测试
uni-admin/ uni-admin/
├── uniCloud-alipay/cloudfunctions/ ├── uniCloud-alipay/cloudfunctions/
@ -1046,9 +1081,8 @@ frontend/static/html/
### 修改文件 ### 修改文件
``` ```
backend/gateway/router/router.go # 注册新路由 backend/gateway/router/router.go # 注册新路由 + 三层装配§4.5
backend/gateway/main.go # 装配依赖注入(或等效入口文件) uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm 后触发同步§5.3
uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm 后触发同步
``` ```
--- ---
@ -1062,12 +1096,15 @@ uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm
| A1 | 编写 migration SQL | 15min | `2026_07_08_001_app_download_configs.sql` | | A1 | 编写 migration SQL | 15min | `2026_07_08_001_app_download_configs.sql` |
| A2 | 编写 Go Model + 常量 | 10min | `app_download_config.go` | | A2 | 编写 Go Model + 常量 | 10min | `app_download_config.go` |
| A3 | 编写 Repository含 struct 定义 + 构造函数) | 20min | `app_download_repository.go` | | A3 | 编写 Repository含 struct 定义 + 构造函数) | 20min | `app_download_repository.go` |
| A4 | 编写 Service | 15min | `app_download_service.go` | | A4 | 编写 Repository 单元测试2 个用例) | 20min | `app_download_repository_test.go` |
| A5 | 编写 Controller + DTO + 构造函数 | 20min | `app_download_controller.go` | | A5 | 编写 Service含请求 DTO + 构造函数) | 20min | `app_download_service.go` |
| A6 | 注册路由 + main.go 装配 | 15min | `router.go` + `main.go` 修改 | | A6 | 编写 Service 单元测试2 个用例) | 20min | `app_download_service_test.go` |
| A7 | `go build` + 本地测试curl 两条 API | 15min | 验证 | | A7 | 编写 Controller + DTO + Swagger 注解 | 25min | `app_download_controller.go` |
| A8 | 编写 Controller 集成测试4 个用例) | 25min | `app_download_controller_test.go` |
| A9 | 在 `SetupRouter` 中三层装配 + 注册路由 | 15min | `router.go` 修改 |
| A10 | `swag init` + `go build` + 本地测试curl 两条 API | 15min | 验证 |
> **Phase A 小计**~1h50min > **Phase A 小计**~3h05min较初版增加 ~1h15min 测试时间)
### 阶段 BuniCloud 自动同步(优先级 P0 ### 阶段 BuniCloud 自动同步(优先级 P0
@ -1098,7 +1135,7 @@ uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm
> **Phase D 小计**~20min > **Phase D 小计**~20min
### 总预估:~4h ### 总预估:~5h30min
--- ---
@ -1151,9 +1188,260 @@ uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm
--- ---
## §13 自审清单(修订版) ## §13 测试策略
### 修改的章节全部§1-§12 重写) ### 13.1 测试范围
按 CLAUDE.md 接口开发规范,本功能测试覆盖如下:
| 层级 | 测试类型 | 覆盖内容 | 工具 |
|------|---------|---------|------|
| Repository | 单元测试 | `FindByType` / `UpsertAll` 的 SQL 正确性 | `go test` + SQLite 内存库 或 PG 测试容器 |
| Service | 单元测试 | `GetAllNativeApp` 返回过滤 / `SyncVersion` upsert 逻辑 | `go test` + mock repository |
| Controller | 集成测试 | GET 公开接口 happy path + empty casePOST admin 接口参数校验 + 正常同步 | `go test` + `httptest` + mock service |
### 13.2 Repository 测试
**文件**`backend/gateway/repository/app_download_repository_test.go`
```go
package repository
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
db.AutoMigrate(&models.AppDownloadConfig{})
return db
}
func TestFindByType_NativeApp(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
// seed: 一条 native_app + 一条 wgt
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "native_app", DownloadURL: "https://cdn.example.com/app.apk", Version: "1.0.5",
})
db.Create(&models.AppDownloadConfig{
Platform: "android", Type: "wgt", DownloadURL: "https://cdn.example.com/wgt.wgt", Version: "1.0.5",
})
configs, err := repo.FindByType(context.Background(), models.AppPackageTypeNativeApp)
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "native_app", configs[0].Type)
}
func TestUpsertAll_InsertAndUpdate(t *testing.T) {
db := setupTestDB(t)
repo := NewAppDownloadRepository(db)
// 首次插入
err := repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v1.apk", Version: "1.0.0"},
})
assert.NoError(t, err)
// 更新同一条
err = repo.UpsertAll(context.Background(), []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://v2.apk", Version: "2.0.0"},
})
assert.NoError(t, err)
// 验证只有 1 条且已更新
var count int64
db.Model(&models.AppDownloadConfig{}).Count(&count)
assert.Equal(t, int64(1), count)
var cfg models.AppDownloadConfig
db.First(&cfg)
assert.Equal(t, "https://v2.apk", cfg.DownloadURL)
assert.Equal(t, "2.0.0", cfg.Version)
}
```
### 13.3 Service 测试
**文件**`backend/gateway/service/app_download_service_test.go`
```go
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/topfans/backend/pkg/models"
)
// mock repository
type mockRepo struct {
mock.Mock
}
func (m *mockRepo) FindByType(ctx context.Context, pkgType string) ([]models.AppDownloadConfig, error) {
args := m.Called(ctx, pkgType)
return args.Get(0).([]models.AppDownloadConfig), args.Error(1)
}
func (m *mockRepo) UpsertAll(ctx context.Context, configs []models.AppDownloadConfig) error {
args := m.Called(ctx, configs)
return args.Error(0)
}
func TestGetAllNativeApp_ReturnsFiltered(t *testing.T) {
repo := new(mockRepo)
svc := NewAppDownloadService(repo) // 注:需要用 interface 替代具体类型,见下方说明
expected := []models.AppDownloadConfig{
{Platform: "android", Type: "native_app", DownloadURL: "https://cdn.example.com/app.apk"},
}
repo.On("FindByType", mock.Anything, models.AppPackageTypeNativeApp).Return(expected, nil)
configs, err := svc.GetAllNativeApp(context.Background())
assert.NoError(t, err)
assert.Len(t, configs, 1)
assert.Equal(t, "native_app", configs[0].Type)
}
func TestSyncVersion_BothPlatforms(t *testing.T) {
repo := new(mockRepo)
svc := NewAppDownloadService(repo)
repo.On("UpsertAll", mock.Anything, mock.MatchedBy(func(configs []models.AppDownloadConfig) bool {
return len(configs) == 2
})).Return(nil)
err := svc.SyncVersion(context.Background(), &SyncVersionRequest{
Android: &PlatformVersionInfo{URL: "https://a.apk", Version: "1.0.5", Type: "native_app"},
IOS: &PlatformVersionInfo{URL: "https://apps.apple.com/...", Version: "1.0.5", Type: "native_app"},
})
assert.NoError(t, err)
repo.AssertExpectations(t)
}
```
> **注意**:以上 mock 示例依赖具体 struct生产代码建议将 `AppDownloadService.repo` 改为 interface 类型以支持 mock 注入。具体做法:在 `app_download_service.go` 中定义 `AppDownloadRepository interface { FindByType(...); UpsertAll(...) }``AppDownloadService` 持有该 interface。
### 13.4 Controller 测试
**文件**`backend/gateway/controller/app_download_controller_test.go`
```go
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
)
// 注:与 service 层同理controller 也应依赖 interface 以便注入 mock service
func TestGetDownloadUrls_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// mockSvc := new(MockAppDownloadService)
// mockSvc.On("GetAllNativeApp", ...).Return(...)
// ctrl := NewAppDownloadController(mockSvc)
// w := httptest.NewRecorder()
// req, _ := http.NewRequest("GET", "/api/v1/app/download-urls", nil)
// router := gin.New()
// router.GET("/api/v1/app/download-urls", ctrl.GetDownloadUrls)
// router.ServeHTTP(w, req)
// assert.Equal(t, 200, w.Code)
// var resp map[string]interface{}
// json.Unmarshal(w.Body.Bytes(), &resp)
// assert.Equal(t, float64(0), resp["code"])
}
func TestGetDownloadUrls_EmptyData(t *testing.T) {
gin.SetMode(gin.TestMode)
// mockSvc := new(MockAppDownloadService)
// mockSvc.On("GetAllNativeApp", ...).Return([]models.AppDownloadConfig{}, nil)
// ... 验证 android/ios 键均为 nullHTTP 200
}
func TestSyncVersion_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
// ... 发送非法 JSON验证 HTTP 400
}
func TestSyncVersion_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// ... 发送合法 JSON验证 HTTP 200
}
```
> Controller 测试骨架已给出。实际编写时注入 mock service 即可。
### 13.5 测试运行
```bash
# 运行全部 app_download 相关测试
cd backend/gateway
go test ./repository/ -run AppDownload -v
go test ./service/ -run AppDownload -v
go test ./controller/ -run AppDownload -v
# 带覆盖率
go test ./repository/ ./service/ ./controller/ -coverprofile=coverage.out
go tool cover -func=coverage.out | grep app_download
```
---
## §14 Swagger API 文档
### 14.1 注解位置
Swagger 注解已内嵌在 §4.4.2 Controller 代码中(`@Summary` / `@Tags` / `@Param` / `@Success` / `@Router`)。执行以下命令自动生成文档:
```bash
cd backend/gateway
swag init --parseDependency --parseInternal
```
### 14.2 生成后的文档
生成后 Swagger UI 可在 `https://api.topfans.com/swagger/index.html` 查看:
| 接口 | Tags | 认证 |
|------|------|------|
| `GET /api/v1/app/download-urls` | App下载页 | 无 |
| `POST /api/v1/admin/app/versions/sync` | Admin | Nginx IP 白名单 |
### 14.3 错误码
| HTTP 状态码 | body.code | 说明 |
|------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | 400 | 请求参数错误(`type` 不在枚举范围 / JSON 格式错误 / 必填字段缺失) |
| 500 | 500 | 服务器内部错误PG 连接异常 / upsert 失败) |
---
## §15 自审清单(修订版)
### 修改的章节§1-§14全部重写 + 新增 §13 测试策略、§14 Swagger 文档)
### 未改动的章节:无 ### 未改动的章节:无
@ -1165,22 +1453,36 @@ uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm
- [x] §2.3 读取链路查询条件(`WHERE type = 'native_app'`)与 §4.4.4 repository 的 `FindByType` 一致 - [x] §2.3 读取链路查询条件(`WHERE type = 'native_app'`)与 §4.4.4 repository 的 `FindByType` 一致
- [x] §1 mermaid 图写 "HTTP POST" 与 §4.1 接口定义、§4.3 请求体一致 - [x] §1 mermaid 图写 "HTTP POST" 与 §4.1 接口定义、§4.3 请求体一致
- [x] §1 核心架构图与 §3.1 表结构一致(含 `type` 字段) - [x] §1 核心架构图与 §3.1 表结构一致(含 `type` 字段)
- [x] §4.4.1 DTO `PlatformVersionInfo` 结构URL + Version + Type与 §5.1 云函数 payload 一致 - [x] §4.4.1 DTO `DownloadUrlResponse` 结构与 §4.4.2 Controller 组装逻辑一致
- [x] §4.4.3 Service `SyncVersionRequest` / `PlatformVersionInfo` 与 §4.4.2 Controller 引用一致(`service.SyncVersionRequest`
- [x] §4.4.3 Service 请求 DTO 与 §5.1 云函数 payload 一致URL + Version + Type
- [x] §5.1 云函数使用 `process.env.BACKEND_URL` 与 §5.2 注释、§9.2 部署步骤一致(均为环境变量方式) - [x] §5.1 云函数使用 `process.env.BACKEND_URL` 与 §5.2 注释、§9.2 部署步骤一致(均为环境变量方式)
- [x] §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径一致 - [x] §10 文件清单与 §4.4 分层目录、§3.2 model 路径、§7.1 HTML 路径、§13 测试文件一致
- [x] §11 实施步骤中的文件名与 §10 文件清单一致 - [x] §11 实施步骤中的文件名与 §10 文件清单一致(含测试文件)
- [x] §8.2 异常恢复不再依赖已删除的独立同步页§6改用编辑重保存 + 列表按钮 - [x] §8.2 异常恢复不再依赖已删除的独立同步页§6改用编辑重保存 + 列表按钮
- [x] §4.4 架构说明指向 §12.5gateway 直连 PG 的决策理由) - [x] §4.4 架构说明指向 §12.5gateway 直连 PG 的决策理由)
- [x] §14 Swagger 注解与 §4.4.2 Controller 代码内嵌的 `@Summary` / `@Tags` / `@Router` 一致
- [x] §13 测试文件路径与 §10 文件清单一致
### Go 代码完整性 ### Go 代码完整性
- [x] DTO `SyncVersionRequest` / `PlatformVersionInfo` / `DownloadUrlResponse` 有完整 struct 定义§4.4.1 - [x] 请求 DTO 定义在 service 包(`SyncVersionRequest` / `PlatformVersionInfo`),避免 controller ↔ service 循环依赖
- [x] 响应 DTO`DownloadUrlResponse`)定义在 controller 包
- [x] Controller / Service / Repository 均有 constructorNewXxx函数 - [x] Controller / Service / Repository 均有 constructorNewXxx函数
- [x] Repository struct 的 `db *gorm.DB` 字段已定义 - [x] Repository struct 的 `db *gorm.DB` 字段已定义,通过 `database.GetDB()` 注入
- [x] 所有文件 import 块完整(`net/http`, `context`, `time`, `gorm.io/gorm`, models, service, repository - [x] Controller 使用 `response.Success` / `response.BadRequest` / `response.InternalError`(与项目统一响应包一致)
- [x] Controller constructor 注明不返回 error 的原因(无 Dubbo 依赖,与 `NewSegmentController` 等一致)
- [x] 所有文件 import 块完整(`context`, `time`, `gorm.io/gorm`, models, service, repository, response
- [x] `UpsertAll` 使用 `Assign(map[string]interface{}{...})` 避免覆盖 `created_at` - [x] `UpsertAll` 使用 `Assign(map[string]interface{}{...})` 避免覆盖 `created_at`
- [x] sync API 的 `type` 字段带 `binding:"required,oneof=native_app wgt"` 校验 - [x] sync API 的 `type` 字段带 `binding:"required,oneof=native_app wgt"` 校验
### 测试代码完整性
- [x] Repository 测试覆盖 `FindByType` 过滤逻辑 + `UpsertAll` insert/update
- [x] Service 测试覆盖 `GetAllNativeApp` 过滤 + `SyncVersion` 双平台场景
- [x] Controller 测试骨架覆盖 4 个场景happy path / empty / invalid / success
- [x] 测试建议将 repo/svc 改为 interface 以支持 mock 注入
### Vue / JS 代码完整性 ### Vue / JS 代码完整性
- [x] `add.vue` 改为 diff 风格(标注原代码位置 + 修改后的代码块),不会覆盖原有 `submitForm` 逻辑 - [x] `add.vue` 改为 diff 风格(标注原代码位置 + 修改后的代码块),不会覆盖原有 `submitForm` 逻辑
@ -1192,7 +1494,10 @@ uni-admin/uni_modules/uni-upgrade-center/pages/version/add.vue # submitForm
### 其他 ### 其他
- [x] §5.2 package.json 去掉了不存在的 `uni-cloud-db` 依赖 - [x] §5.2 package.json 去掉了不存在的 `uni-cloud-db` 依赖
- [x] §4.4 controller 使用 `http.StatusOK` 等常量而非魔法数字 - [x] Controller 使用 `response` 包统一响应格式(非裸 `c.JSON` + 魔法数字)
- [x] 文档开头方案概述§1符合 CLAUDE.md 要求 - [x] 文档开头方案概述§1符合 CLAUDE.md 要求
- [x] 未违反 MVP 原则 - [x] 未违反 MVP 原则§12.5 充分论证 gateway 直连 PG
- [x] Admin 路由与现有 pattern 一致 - [x] Admin 路由与现有 group 集成(不新建 group
- [x] 装配在 `SetupRouter` 内部完成(不修改 `main.go` / `SetupRouter` 签名)
- [x] Swagger 注解与 `swag init` 生成流程已说明
- [x] 测试运行命令已给出

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TopFans — 下载</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #0a0a0a 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.container { text-align: center; padding: 40px 24px; max-width: 360px; width: 100%; }
.logo { font-size: 32px; font-weight: 800; letter-spacing: 2px; margin-bottom: 8px; }
.slogan { font-size: 14px; color: #888; margin-bottom: 32px; }
.version { font-size: 13px; color: #666; margin-bottom: 24px; min-height: 20px; }
.download-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 16px 24px;
margin-bottom: 16px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: transform 0.15s, opacity 0.15s;
}
.download-btn:active { transform: scale(0.97); }
.download-btn.android { background: #3ddc84; color: #000; }
.download-btn.ios { background: #fff; color: #000; }
.download-btn.disabled { opacity: 0.4; pointer-events: none; }
.footer { margin-top: 40px; font-size: 12px; color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="logo">TopFans</div>
<div class="slogan">粉丝共创平台</div>
<div class="version" id="version-info">加载中…</div>
<a id="btn-android" class="download-btn android disabled" href="javascript:void(0)">
🤖 Android 下载
</a>
<a id="btn-ios" class="download-btn ios disabled" href="javascript:void(0)">
iOS 下载
</a>
<div class="footer">TopFans &copy; 2026</div>
</div>
<script>
(async function() {
// ★ 部署时修改为实际 API 地址
var API_URL = 'https://api.topfans.com/api/v1/app/download-urls';
var btnAndroid = document.getElementById('btn-android');
var btnIOS = document.getElementById('btn-ios');
var versionInfo = document.getElementById('version-info');
var versions = [];
function updateUI() {
var hasAndroid = false;
var hasIOS = false;
if (versions.length > 0) {
for (var i = 0; i < versions.length; i++) {
var v = versions[i];
if (v.platform === 'android') {
btnAndroid.href = v.url;
btnAndroid.classList.remove('disabled');
hasAndroid = true;
}
if (v.platform === 'ios') {
btnIOS.href = v.url;
btnIOS.classList.remove('disabled');
hasIOS = true;
}
}
}
if (hasAndroid || hasIOS) {
var parts = [];
if (hasAndroid) parts.push('Android ' + getVersionText('android'));
if (hasIOS) parts.push('iOS ' + getVersionText('ios'));
versionInfo.textContent = '最新版本:' + parts.join(' | ');
} else {
versionInfo.textContent = '暂无可用下载';
}
}
function getVersionText(platform) {
for (var i = 0; i < versions.length; i++) {
if (versions[i].platform === platform) {
return versions[i].version || '';
}
}
return '';
}
try {
var res = await fetch(API_URL);
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
var json = await res.json();
if (json.code === 0 && json.data) {
['android', 'ios'].forEach(function(p) {
if (json.data[p]) {
versions.push({
platform: p,
url: json.data[p].url,
version: json.data[p].version,
type: json.data[p].type
});
}
});
updateUI();
} else {
versionInfo.textContent = '暂无可用下载';
}
} catch (err) {
console.error('获取下载地址失败', err);
versionInfo.textContent = '获取下载地址失败,请稍后重试';
}
})();
</script>
</body>
</html>