From b79b781f15bbd36e98b482726b6cb3db0ec7b361 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 18 Oct 2026 18:11:34 +0800 Subject: [PATCH 1/5] feat: add object storage and points leaderboard --- docs/移动端任务与账户平台前后端对接文档.md | 4 +- go.mod | 16 +- go.sum | 27 +++ internal/app/app.go | 25 ++- internal/attachments/service.go | 73 ++++++- internal/attachments/service_test.go | 11 + internal/config/config.go | 76 ++++++- internal/config/config_test.go | 26 +++ internal/imclient/client.go | 2 +- internal/portal/service.go | 12 +- internal/portal/service_test.go | 6 +- internal/store/attachments.go | 5 +- internal/store/portal.go | 12 +- internal/store/upload_config.go | 20 ++ web/index.html | 2 +- web/public/images/olons-banner.svg | 7 + web/public/images/olons-product.svg | 7 + web/src/mobile/im.ts | 2 +- web/src/mobile/views/ClaimView.vue | 6 +- web/src/mobile/views/HomeView.vue | 9 +- web/src/mobile/views/LeaderboardView.vue | 13 +- web/src/mobile/views/PlatformIntroView.vue | 2 +- web/src/mobile/views/platformIntroContent.ts | 206 +++++-------------- web/src/router/index.ts | 2 +- 24 files changed, 359 insertions(+), 212 deletions(-) create mode 100644 internal/attachments/service_test.go create mode 100644 internal/store/upload_config.go create mode 100644 web/public/images/olons-banner.svg create mode 100644 web/public/images/olons-product.svg diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md index 66c42b4..0352035 100644 --- a/docs/移动端任务与账户平台前后端对接文档.md +++ b/docs/移动端任务与账户平台前后端对接文档.md @@ -481,7 +481,7 @@ GET /v1/member-levels GET /v1/leaderboard ``` -接口要求登录,返回当前租户内已完成任务数量排名前 50 的有效会员。排名只统计 +接口要求登录,返回当前租户内已完成领取订单总金额排名前 50 的有效会员。页面将订单总金额展示为领取积分,接口字段 `claimed_points` 的单位为分。排名只统计 `hg_trade_order.order_type=2 AND order_status=4` 的真实任务订单;昵称由服务端脱敏,前端不得还原或展示手机号。 ```json @@ -492,7 +492,7 @@ GET /v1/leaderboard "rank": 1, "username": "风***淡", "avatar_url": "https://cdn.example.com/avatar.png", - "completed_count": 12580, + "claimed_points": 125800, "status": "领取成功" } ] diff --git a/go.mod b/go.mod index 7c5ba69..37790f8 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.26.0 require ( github.com/go-sql-driver/mysql v1.10.0 + github.com/gorilla/websocket v1.5.3 github.com/jmoiron/sqlx v1.4.0 + github.com/minio/minio-go/v7 v7.0.94 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.12.1 golang.org/x/crypto v0.53.0 @@ -15,13 +17,25 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/text v0.2.0 // indirect + github.com/minio/crc64nvme v1.0.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 9c4fe25..e1c0074 100644 --- a/go.sum +++ b/go.sum @@ -14,17 +14,28 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -35,8 +46,16 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= +github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM= +github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -51,16 +70,24 @@ github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgv github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/app.go b/internal/app/app.go index e29a834..898e051 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -53,9 +53,30 @@ func New(cfg *config.Config) (*App, error) { taskService := tasks.New(st) mallService := mall.New(st) financeService := finance.New(st, redisClient, cfg.Auth) - attachmentService := attachments.New(st, cfg.Upload) + uploadConfig := cfg.Upload + uploadCtx, uploadCancel := context.WithTimeout(context.Background(), 5*time.Second) + uploadValues, err := st.UploadConfigValues(uploadCtx) + uploadCancel() + if err != nil { + _ = redisClient.Close() + _ = st.Close() + return nil, err + } + config.ApplyUploadSystemConfig(&uploadConfig, uploadValues) + config.ApplyUploadEnv(&uploadConfig) + if err = config.ValidateUpload(uploadConfig); err != nil { + _ = redisClient.Close() + _ = st.Close() + return nil, err + } + attachmentService, err := attachments.New(st, uploadConfig) + if err != nil { + _ = redisClient.Close() + _ = st.Close() + return nil, err + } supportService := support.New(st, imclient.New(cfg.IM), cfg.Auth) - return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil + return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: uploadConfig}, nil } func (a *App) Handler() http.Handler { diff --git a/internal/attachments/service.go b/internal/attachments/service.go index bfd9fde..e32f642 100644 --- a/internal/attachments/service.go +++ b/internal/attachments/service.go @@ -1,6 +1,7 @@ package attachments import ( + "bytes" "context" "crypto/md5" "crypto/rand" @@ -8,12 +9,17 @@ import ( "errors" "io" "net/http" + "net/url" "os" + "path" "path/filepath" "strings" + "time" "git.118im.com/im_team/olons_server/internal/config" "git.118im.com/im_team/olons_server/internal/store" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" ) const maxImageSize = 5 << 20 @@ -27,10 +33,23 @@ type Repository interface { type Service struct { repository Repository cfg config.UploadConfig + minio *minio.Client } -func New(repository Repository, cfg config.UploadConfig) *Service { - return &Service{repository: repository, cfg: cfg} +func New(repository Repository, cfg config.UploadConfig) (*Service, error) { + service := &Service{repository: repository, cfg: cfg} + if strings.EqualFold(cfg.Driver, "minio") { + client, err := minio.New(strings.TrimSpace(cfg.Endpoint), &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.Secure, + Region: cfg.Region, + }) + if err != nil { + return nil, err + } + service.minio = client + } + return service, nil } func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) { @@ -52,22 +71,54 @@ func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, return nil, err } fileName := hex.EncodeToString(random) + ext - if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil { - return nil, err - } - path := filepath.Join(s.cfg.Dir, fileName) - if err = os.WriteFile(path, data, 0o640); err != nil { + storedPath, publicURL, err := s.store(ctx, fileName, mimeType, data) + if err != nil { return nil, err } digest := md5.Sum(data) - publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{ - MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path, - URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]), + MemberID: memberID, Drive: strings.ToLower(strings.TrimSpace(s.cfg.Driver)), Name: filepath.Base(name), MimeType: mimeType, Path: storedPath, + URL: publicURL, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]), }) if err != nil { - _ = os.Remove(path) + s.remove(ctx, storedPath) return nil, err } return attachment, nil } + +func (s *Service) store(ctx context.Context, fileName, mimeType string, data []byte) (string, string, error) { + if s.minio != nil { + objectName := path.Join(strings.Trim(s.cfg.Path, "/"), time.Now().UTC().Format("2006/01/02"), fileName) + _, err := s.minio.PutObject(ctx, s.cfg.Bucket, objectName, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: mimeType}) + if err != nil { + return "", "", err + } + return objectName, minioPublicURL(s.cfg.Domain, s.cfg.Bucket, objectName), nil + } + if err := os.MkdirAll(s.cfg.Dir, 0o750); err != nil { + return "", "", err + } + localPath := filepath.Join(s.cfg.Dir, fileName) + if err := os.WriteFile(localPath, data, 0o640); err != nil { + return "", "", err + } + return localPath, strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName, nil +} + +func (s *Service) remove(ctx context.Context, storedPath string) { + if s.minio != nil { + _ = s.minio.RemoveObject(ctx, s.cfg.Bucket, storedPath, minio.RemoveObjectOptions{}) + return + } + _ = os.Remove(storedPath) +} + +func minioPublicURL(domain, bucket, objectName string) string { + base := strings.TrimRight(strings.TrimSpace(domain), "/") + segments := strings.Split(strings.Trim(objectName, "/"), "/") + for i := range segments { + segments[i] = url.PathEscape(segments[i]) + } + return base + "/" + url.PathEscape(bucket) + "/" + strings.Join(segments, "/") +} diff --git a/internal/attachments/service_test.go b/internal/attachments/service_test.go new file mode 100644 index 0000000..fb5c832 --- /dev/null +++ b/internal/attachments/service_test.go @@ -0,0 +1,11 @@ +package attachments + +import "testing" + +func TestMinioPublicURL(t *testing.T) { + got := minioPublicURL("https://minio.example.com/", "bucket", "images/2026/10/17/a b.jpg") + want := "https://minio.example.com/bucket/images/2026/10/17/a%20b.jpg" + if got != want { + t.Fatalf("minioPublicURL() = %q, want %q", got, want) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1b2cf18..2d70cd3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,8 +37,17 @@ type IMConfig struct { } type UploadConfig struct { + Driver string `json:"driver"` Dir string `json:"dir"` PublicPath string `json:"public_path"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Endpoint string `json:"endpoint"` + Secure bool `json:"secure"` + Path string `json:"path"` + Bucket string `json:"bucket"` + Domain string `json:"domain"` + Region string `json:"region"` } type MySQLConfig struct { @@ -100,7 +109,7 @@ func defaultConfig() *Config { DefaultTenantID: 0, RequiredAPIKey: true, }, - Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"}, + Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"}, IM: IMConfig{ BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im", WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file", @@ -130,8 +139,7 @@ func applyEnv(cfg *Config) { setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY") setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH") setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY") - setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR") - setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH") + ApplyUploadEnv(&cfg.Upload) setString(&cfg.IM.BaseURL, "OLONS_IM_BASE_URL") setString(&cfg.IM.HTTPAPIURL, "OLONS_IM_HTTP_API_URL") setString(&cfg.IM.WebSocketURL, "OLONS_IM_WS_URL") @@ -172,8 +180,8 @@ func (c *Config) validate() error { if c.Auth.DefaultTenantID < 0 { return errors.New("default tenant id must not be negative") } - if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") { - return errors.New("upload config is invalid") + if err := ValidateUpload(c.Upload); err != nil { + return err } if c.IM.Timeout <= 0 { c.IM.Timeout = 8 * time.Second @@ -181,6 +189,64 @@ func (c *Config) validate() error { return nil } +func ApplyUploadSystemConfig(cfg *UploadConfig, values map[string]string) { + if cfg == nil { + return + } + setMapString(&cfg.Driver, values, "uploadDrive") + setMapString(&cfg.AccessKey, values, "uploadMinioAccessKey") + setMapString(&cfg.SecretKey, values, "uploadMinioSecretKey") + setMapString(&cfg.Endpoint, values, "uploadMinioEndpoint") + setMapString(&cfg.Path, values, "uploadMinioPath") + setMapString(&cfg.Bucket, values, "uploadMinioBucket") + setMapString(&cfg.Domain, values, "uploadMinioDomain") + if value := strings.TrimSpace(values["uploadMinioUseSSL"]); value != "" { + cfg.Secure = value == "1" || strings.EqualFold(value, "true") + } +} + +func ApplyUploadEnv(cfg *UploadConfig) { + if cfg == nil { + return + } + setString(&cfg.Driver, "OLONS_SERVER_UPLOAD_DRIVER") + setString(&cfg.Dir, "OLONS_SERVER_UPLOAD_DIR") + setString(&cfg.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH") + setString(&cfg.AccessKey, "OLONS_SERVER_MINIO_ACCESS_KEY") + setString(&cfg.SecretKey, "OLONS_SERVER_MINIO_SECRET_KEY") + setString(&cfg.Endpoint, "OLONS_SERVER_MINIO_ENDPOINT") + setString(&cfg.Path, "OLONS_SERVER_MINIO_PATH") + setString(&cfg.Bucket, "OLONS_SERVER_MINIO_BUCKET") + setString(&cfg.Domain, "OLONS_SERVER_MINIO_DOMAIN") + setString(&cfg.Region, "OLONS_SERVER_MINIO_REGION") + if value := strings.TrimSpace(os.Getenv("OLONS_SERVER_MINIO_SECURE")); value != "" { + cfg.Secure = value == "1" || strings.EqualFold(value, "true") + } +} + +func ValidateUpload(cfg UploadConfig) error { + switch strings.ToLower(strings.TrimSpace(cfg.Driver)) { + case "local": + if strings.TrimSpace(cfg.Dir) == "" || !strings.HasPrefix(cfg.PublicPath, "/") { + return errors.New("local upload config is invalid") + } + case "minio": + if strings.TrimSpace(cfg.AccessKey) == "" || strings.TrimSpace(cfg.SecretKey) == "" || + strings.TrimSpace(cfg.Endpoint) == "" || strings.TrimSpace(cfg.Bucket) == "" || strings.TrimSpace(cfg.Domain) == "" { + return errors.New("minio upload config is incomplete") + } + default: + return errors.New("upload driver is unsupported") + } + return nil +} + +func setMapString(target *string, values map[string]string, key string) { + if value := strings.TrimSpace(values[key]); value != "" { + *target = value + } +} + func setString(target *string, key string) { if value := strings.TrimSpace(os.Getenv(key)); value != "" { *target = value diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cdf1094..a8cd467 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,3 +19,29 @@ func TestConfigRejectsOtherDatabase(t *testing.T) { t.Fatal("expected non-olons_admin database to be rejected") } } + +func TestApplyUploadSystemConfig(t *testing.T) { + cfg := UploadConfig{Driver: "local", Region: "us-east-1"} + ApplyUploadSystemConfig(&cfg, map[string]string{ + "uploadDrive": "minio", + "uploadMinioAccessKey": "access", + "uploadMinioSecretKey": "secret", + "uploadMinioEndpoint": "minio.example.com", + "uploadMinioUseSSL": "1", + "uploadMinioPath": "images/", + "uploadMinioBucket": "bucket", + "uploadMinioDomain": "https://cdn.example.com", + }) + if err := ValidateUpload(cfg); err != nil { + t.Fatal(err) + } + if cfg.Driver != "minio" || !cfg.Secure || cfg.Bucket != "bucket" || cfg.Path != "images/" { + t.Fatalf("unexpected upload config: %+v", cfg) + } +} + +func TestValidateUploadRejectsIncompleteMinio(t *testing.T) { + if err := ValidateUpload(UploadConfig{Driver: "minio"}); err == nil { + t.Fatal("expected incomplete minio config to be rejected") + } +} diff --git a/internal/imclient/client.go b/internal/imclient/client.go index cddafdb..3cac201 100644 --- a/internal/imclient/client.go +++ b/internal/imclient/client.go @@ -205,7 +205,7 @@ func (c *Client) ResetPassword(ctx context.Context, userID, password string) err } path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action" return c.internalJSON(ctx, http.MethodPost, path, map[string]any{ - "event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "Olons客服系统凭据同步", + "event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "悦选客服系统凭据同步", }, nil) } diff --git a/internal/portal/service.go b/internal/portal/service.go index 91c31a1..e8b5016 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -110,11 +110,11 @@ type MemberLevel struct { } type LeaderboardEntry struct { - Rank int `json:"rank"` - Username string `json:"username"` - AvatarURL string `json:"avatar_url"` - CompletedCount int64 `json:"completed_count"` - Status string `json:"status"` + Rank int `json:"rank"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + ClaimedPoints int64 `json:"claimed_points"` + Status string `json:"status"` } type CustomerService struct { @@ -287,7 +287,7 @@ func (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]Leaderboar items := make([]LeaderboardEntry, 0, len(rows)) for index, row := range rows { items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username), - AvatarURL: row.AvatarURL, CompletedCount: row.CompletedCount, Status: "领取成功"}) + AvatarURL: row.AvatarURL, ClaimedPoints: row.ClaimedPoints, Status: "领取成功"}) } return items, nil } diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go index 4934e98..d324cb5 100644 --- a/internal/portal/service_test.go +++ b/internal/portal/service_test.go @@ -153,15 +153,15 @@ func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) { func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) { service := New(fakeRepository{leaders: []store.LeaderboardMember{ - {MemberID: 7, Username: "风轻云淡", CompletedCount: 12}, - {MemberID: 8, Username: "李明", CompletedCount: 9}, + {MemberID: 7, Username: "风轻云淡", ClaimedPoints: 125800}, + {MemberID: 8, Username: "李明", ClaimedPoints: 96000}, }}) items, err := service.Leaderboard(context.Background(), 1) if err != nil { t.Fatal(err) } if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" || - items[1].Username != "李*" || items[1].Status != "领取成功" { + items[0].ClaimedPoints != 125800 || items[1].Username != "李*" || items[1].Status != "领取成功" { t.Fatalf("unexpected leaderboard: %+v", items) } } diff --git a/internal/store/attachments.go b/internal/store/attachments.go index 1cb4d82..09aba44 100644 --- a/internal/store/attachments.go +++ b/internal/store/attachments.go @@ -4,6 +4,7 @@ import "context" type AttachmentCreation struct { MemberID int64 + Drive string Name string MimeType string Path string @@ -21,8 +22,8 @@ type Attachment struct { func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment (app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at) - VALUES('olons_server',?,0,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`, - input.MemberID, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5) + VALUES('olons_server',?,0,?,?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`, + input.MemberID, input.Drive, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5) if err != nil { return nil, err } diff --git a/internal/store/portal.go b/internal/store/portal.go index 5dfcbe7..87349a1 100644 --- a/internal/store/portal.go +++ b/internal/store/portal.go @@ -68,10 +68,10 @@ type TaskSummary struct { } type LeaderboardMember struct { - MemberID int64 `db:"member_id"` - Username string `db:"username"` - AvatarURL string `db:"avatar_url"` - CompletedCount int64 `db:"completed_count"` + MemberID int64 `db:"member_id"` + Username string `db:"username"` + AvatarURL string `db:"avatar_url"` + ClaimedPoints int64 `db:"claimed_points"` } type CustomerService struct { @@ -250,7 +250,7 @@ func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int) } rows := make([]LeaderboardMember, 0) err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username, - COALESCE(a.file_url,'') AS avatar_url,COUNT(o.id) AS completed_count + COALESCE(a.file_url,'') AS avatar_url,COALESCE(SUM(o.total_amount),0) AS claimed_points FROM hg_app_member m LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1 @@ -258,7 +258,7 @@ func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int) AND o.order_type=2 AND o.order_status=4 WHERE m.tenant_id=? AND m.member_status=1 AND m.deleted_at IS NULL GROUP BY m.id,m.username,a.file_url - ORDER BY completed_count DESC,m.id ASC LIMIT ?`, tenantID, limit) + ORDER BY claimed_points DESC,m.id ASC LIMIT ?`, tenantID, limit) return rows, err } diff --git a/internal/store/upload_config.go b/internal/store/upload_config.go new file mode 100644 index 0000000..5c7da3e --- /dev/null +++ b/internal/store/upload_config.go @@ -0,0 +1,20 @@ +package store + +import "context" + +func (s *Store) UploadConfigValues(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT `key`, `value` FROM hg_sys_config WHERE `group`='upload' AND status=1") + if err != nil { + return nil, err + } + defer rows.Close() + values := make(map[string]string) + for rows.Next() { + var key, value string + if err = rows.Scan(&key, &value); err != nil { + return nil, err + } + values[key] = value + } + return values, rows.Err() +} diff --git a/web/index.html b/web/index.html index 2ea82df..3917dc5 100644 --- a/web/index.html +++ b/web/index.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" /> - Olons + 悦选
diff --git a/web/public/images/olons-banner.svg b/web/public/images/olons-banner.svg new file mode 100644 index 0000000..1f661c8 --- /dev/null +++ b/web/public/images/olons-banner.svg @@ -0,0 +1,7 @@ + + + + + 悦选 + 精选商品 · 安心服务 + diff --git a/web/public/images/olons-product.svg b/web/public/images/olons-product.svg new file mode 100644 index 0000000..baa18af --- /dev/null +++ b/web/public/images/olons-product.svg @@ -0,0 +1,7 @@ + + + + + + 悦选 + diff --git a/web/src/mobile/im.ts b/web/src/mobile/im.ts index 8e8293f..ab5ac79 100644 --- a/web/src/mobile/im.ts +++ b/web/src/mobile/im.ts @@ -38,7 +38,7 @@ export class CustomerServiceChat { this.currentUserID = session.current_user_id; this.onMessages = onMessages; this.tinode = new Tinode({ - appName: 'Olons Web', + appName: '悦选', host: endpoint.host, apiKey: session.api_key, transport: 'ws', diff --git a/web/src/mobile/views/ClaimView.vue b/web/src/mobile/views/ClaimView.vue index 09aa4c3..d893196 100644 --- a/web/src/mobile/views/ClaimView.vue +++ b/web/src/mobile/views/ClaimView.vue @@ -5,7 +5,7 @@ 每日参与任务,赚取更多佣金