diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md index 66c42b4..8c50863 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": "领取成功" } ] @@ -743,7 +743,7 @@ GET /v1/manual-products?page=1&page_size=20&category_id=0 } ``` -金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 统计该商品 `order_type=3` 且状态不是已取消或失败的订单。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。 +金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 直接读取商品表中的固定展示数,不在页面或接口中重新计算。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。 ### 8.2 商品详情 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/finance/service.go b/internal/finance/service.go index 7cd4126..68dd128 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -803,7 +803,10 @@ func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) e return err } var config map[string]any - if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") { + if json.Unmarshal(raw, &config) != nil { + config = map[string]any{} + } + if enabled, found := findBoolValue(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"); found && !enabled { return store.ErrWithdrawalDisabled } return nil @@ -1142,25 +1145,28 @@ func allDigits(value string) bool { return value != "" } -func findBool(config map[string]any, keys ...string) bool { +func findBoolValue(config map[string]any, keys ...string) (bool, bool) { for _, key := range keys { for candidate, value := range config { if strings.EqualFold(candidate, key) { switch typed := value.(type) { case bool: - return typed + return typed, true case float64: - return typed == 1 + return typed == 1, true case string: - return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") + return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on"), true } + return false, true } } } for _, value := range config { - if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) { - return true + if nested, ok := value.(map[string]any); ok { + if result, found := findBoolValue(nested, keys...); found { + return result, true + } } } - return false + return false, false } diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 2692f4b..236b4a1 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -118,11 +118,18 @@ func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) { } } -func TestQuoteFailsClosed(t *testing.T) { +func TestQuoteDefaultsToEnabledWithoutWithdrawalSwitch(t *testing.T) { service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now} + if err := service.ensureWithdrawalEnabled(context.Background(), 0); err != nil { + t.Fatalf("missing switch should default to enabled, got %v", err) + } +} + +func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) { + service := &Service{repository: &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":false}}`)}, ready: true, now: time.Now} _, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"}) if !errors.Is(err, store.ErrWithdrawalDisabled) { - t.Fatalf("expected disabled error, got %v", err) + t.Fatalf("expected explicitly disabled withdrawal, got %v", err) } } 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..805e8fb 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 } @@ -328,30 +328,40 @@ func maskedUsername(value string) string { func parseFeatures(raw json.RawMessage) Features { var config map[string]any if json.Unmarshal(raw, &config) != nil { - return Features{} + config = map[string]any{} } return Features{ - TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"), - RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"), - WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), - ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), + TaskEnabled: findBoolDefault(config, true, "task_enabled", "taskEnabled", "TASK"), + RechargeEnabled: findBoolDefault(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"), + WithdrawalEnabled: findBoolDefault(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), + ManualMallEnabled: findBoolDefault(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), } } -func findBool(config map[string]any, keys ...string) bool { - for _, key := range keys { - if value, ok := config[key]; ok { - return boolValue(value) - } +func findBoolDefault(config map[string]any, defaultValue bool, keys ...string) bool { + value, found := findBoolValue(config, keys...) + if !found { + return defaultValue } - if nested, ok := config["features"].(map[string]any); ok { - for _, key := range keys { - if value, found := nested[key]; found { - return boolValue(value) + return value +} + +func findBoolValue(config map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + for candidate, value := range config { + if strings.EqualFold(candidate, key) { + return boolValue(value), true } } } - return false + for _, value := range config { + if nested, ok := value.(map[string]any); ok { + if result, found := findBoolValue(nested, keys...); found { + return result, true + } + } + } + return false, false } func boolValue(value any) bool { diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go index 4934e98..ff49b09 100644 --- a/internal/portal/service_test.go +++ b/internal/portal/service_test.go @@ -77,6 +77,18 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) { } } +func TestParseFeaturesDefaultsMissingSwitchesToEnabled(t *testing.T) { + features := parseFeatures(json.RawMessage(`{}`)) + if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled { + t.Fatalf("missing switches should default to enabled: %+v", features) + } + + features = parseFeatures(json.RawMessage(`{"features":{"task_enabled":false,"withdrawal_enabled":false}}`)) + if features.TaskEnabled || features.WithdrawalEnabled || !features.RechargeEnabled || !features.ManualMallEnabled { + t.Fatalf("explicitly disabled switches must remain disabled: %+v", features) + } +} + func TestBannersSanitizeTargets(t *testing.T) { service := New(fakeRepository{banners: []store.Banner{ {ID: 1, TargetType: 2, TargetValue: "/receive"}, @@ -153,15 +165,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/manual_products.go b/internal/store/manual_products.go index 2ddbe51..f580073 100644 --- a/internal/store/manual_products.go +++ b/internal/store/manual_products.go @@ -73,8 +73,7 @@ func manualProductSelectSQL() string { COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended, COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at, COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at, - (SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id - AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count, + p.claimed_count, DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at FROM hg_product p JOIN hg_product_category c 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/tasks.go b/internal/store/tasks.go index 7260272..50be719 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -205,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder if wallet.Status != 1 { return nil, ErrAccountRestricted } - if member.AutoDispatch == 1 && (wallet.AvailableAmount <= 0 || wallet.AvailableAmount < input.MinimumAmount) { + if wallet.AvailableAmount < input.MinimumAmount { return nil, ErrInsufficientBalance } var pending int64 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/internal/tasks/service.go b/internal/tasks/service.go index db00067..bb57301 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -18,10 +18,11 @@ const ( defaultPageSize = 20 maxPageSize = 100 defaultDeadlineMinute = 15 + minimumClaimAmount = 29900 + businessDisplayStart = "09:00:00+08:00" + businessDisplayEnd = "21:00:00+08:00" ) -var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60) - type Repository interface { CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) @@ -158,8 +159,6 @@ type taskPolicy struct { AutoDelay int AutoMinAmount int64 AutoMaxAmount int64 - Start string - End string Deadline time.Duration } @@ -194,20 +193,18 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in } result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount, TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID, - BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"} + BusinessStartAt: businessDisplayStart, BusinessEndAt: businessDisplayEnd} switch { case !policy.Enabled: result.reject("TASK_DISABLED", "任务功能未开启") - case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End): - result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间") case row.MemberStatus != 1: result.reject("ACCOUNT_RESTRICTED", "账户状态受限") case !row.HallEligible: result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅") case row.WalletStatus != 1: result.reject("ACCOUNT_RESTRICTED", "账户状态受限") - case row.AutoDispatch == 1 && (row.AvailableAmount <= 0 || row.AvailableAmount < policy.MinimumAmount): - result.reject("INSUFFICIENT_BALANCE", "可用余额不足") + case row.AvailableAmount < policy.MinimumAmount: + result.reject("INSUFFICIENT_BALANCE", "可用余额不足299元") case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit: result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限") case row.PendingOrderID > 0: @@ -233,9 +230,6 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) ( if !policy.Enabled { return nil, ErrTaskDisabled } - if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) { - return nil, ErrOutsideBusinessHour - } now := s.now().UTC() row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID, HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay, @@ -379,14 +373,14 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error if minutes <= 0 || minutes > 24*60 { minutes = defaultDeadlineMinute } - start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00") - end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59") - return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")), - MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))), + enabledValue := findValue(config, "task_enabled", "taskEnabled", "TASK") + enabled := enabledValue == nil || boolValue(enabledValue) + return taskPolicy{Enabled: enabled, + MinimumAmount: minimumClaimAmount, AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))), AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))), AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))), - Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil + Deadline: time.Duration(minutes) * time.Minute}, nil } func orderFromStore(row store.TaskOrder, detail bool) Order { @@ -506,38 +500,6 @@ func submissionStatusText(status int) string { return names[status] } -func withinBusinessHours(now time.Time, start, end string) bool { - current := now.Hour()*3600 + now.Minute()*60 + now.Second() - startSecond := parseClock(start) - endSecond := parseClock(end) - if startSecond <= endSecond { - return current >= startSecond && current <= endSecond - } - return current >= startSecond || current <= endSecond -} - -func parseClock(value string) int { - parsed, err := time.Parse("15:04:05", value) - if err != nil { - return 0 - } - return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second() -} - -func clockValue(value any, fallback string) string { - raw, ok := value.(string) - if !ok { - return fallback - } - raw = strings.TrimSpace(raw) - for _, layout := range []string{"15:04:05", "15:04"} { - if parsed, err := time.Parse(layout, raw); err == nil { - return parsed.Format("15:04:05") - } - } - return fallback -} - func findValue(config map[string]any, keys ...string) any { for _, key := range keys { for candidate, value := range config { diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go index 26ecf49..d11b772 100644 --- a/internal/tasks/service_test.go +++ b/internal/tasks/service_test.go @@ -62,58 +62,66 @@ func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ( return []map[string]any{}, nil } -func TestEligibilityAppliesPublishedPolicy(t *testing.T) { - repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), - eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} +func TestEligibilityRequiresFixedMinimumBalance(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0}`), + eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} service := New(repository) - service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) } result, err := service.Eligibility(context.Background(), 0, 10, 1) if err != nil { t.Fatal(err) } - if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 50000 { + if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 29900 { t.Fatalf("unexpected eligibility: %#v", result) } } -func TestEligibilityAllowsManualDispatchBelowMinimumBalance(t *testing.T) { - repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), - eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}} +func TestEligibilityAlsoRequiresMinimumForManualDispatch(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true}}`), + eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5}} service := New(repository) - service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) } - - result, err := service.Eligibility(context.Background(), 0, 10, 1) - if err != nil { - t.Fatal(err) - } - if !result.Eligible { - t.Fatalf("manual dispatch should ignore the automatic minimum balance gate: %#v", result) - } -} - -func TestEligibilityRejectsAutoDispatchWithZeroBalanceWhenMinimumIsZero(t *testing.T) { - repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), - eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}} - service := New(repository) - service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) } result, err := service.Eligibility(context.Background(), 0, 10, 1) if err != nil { t.Fatal(err) } if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" { - t.Fatalf("zero balance must not allow automatic claiming: %#v", result) + t.Fatalf("manual dispatch must use the same minimum balance gate: %#v", result) } } -func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) { +func TestEligibilityDoesNotEnforceDisplayedBusinessHours(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_business_start_at":"09:00","task_business_end_at":"21:00"}`), + eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29900, WalletStatus: 1, DailyLimit: 5}} + service := New(repository) + service.now = func() time.Time { return time.Date(2026, 10, 9, 23, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) } + + result, err := service.Eligibility(context.Background(), 0, 10, 1) + if err != nil { + t.Fatal(err) + } + if !result.Eligible || result.BusinessStartAt != "09:00:00+08:00" || result.BusinessEndAt != "21:00:00+08:00" { + t.Fatalf("business hours are display-only: %#v", result) + } +} + +func TestClaimDefaultsToEnabledWithoutTaskSwitch(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{}`)} service := New(repository) + service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) } + + if _, err := service.Claim(context.Background(), 0, 10, 1); err != nil { + t.Fatalf("missing switch should default to enabled, got %v", err) + } +} + +func TestClaimHonorsExplicitDisabledTaskSwitch(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":false}}`)} + service := New(repository) _, err := service.Claim(context.Background(), 0, 10, 1) if !errors.Is(err, ErrTaskDisabled) { - t.Fatalf("expected task disabled, got %v", err) + t.Fatalf("expected explicitly disabled task, got %v", err) } } @@ -128,7 +136,7 @@ func TestClaimBuildsMatchingOrder(t *testing.T) { t.Fatal(err) } if result.Status != "matching" || repository.claimed.HallID != 2 || - !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { + repository.claimed.MinimumAmount != 29900 || !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed) } } 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/api.ts b/web/src/mobile/api.ts index c787efc..54889aa 100644 --- a/web/src/mobile/api.ts +++ b/web/src/mobile/api.ts @@ -13,7 +13,7 @@ const reasonMessages: Record = { RATE_LIMITED: '操作过于频繁,请稍后再试', TASK_DISABLED: '领取功能暂未开放', OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内', - INSUFFICIENT_BALANCE: '可用余额不足', + INSUFFICIENT_BALANCE: '可用余额不足299元,暂时无法领取', DAILY_LIMIT_REACHED: '今日领取次数已用完', PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理', NO_TASK_AVAILABLE: '当前暂无可领取任务', 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/styles.css b/web/src/mobile/styles.css index ac06954..2a7b200 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -3543,105 +3543,40 @@ body { /* Olons 2 manual task mall */ .m-task-mall { min-height: 100dvh; - padding: calc(18px + env(safe-area-inset-top)) 20px 32px; + padding: 0 12px 32px; background-color: #fffaf4; background-size: 100% auto; background-repeat: no-repeat; } -.m-mall-search { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 12px; -} -.m-mall-search h1 { - margin: 0; - font-size: 28px; -} -.m-mall-search label { - height: 48px; - min-width: 0; - padding: 0 14px; - border-radius: 25px; - background: #fff; - color: #aaa; - display: flex; - align-items: center; - gap: 8px; -} -.m-mall-search label .n-icon { - font-size: 24px; -} -.m-mall-search input { - width: 100%; - min-width: 0; - border: 0; - outline: 0; - background: transparent; -} -.m-mall-search > button, -.m-mall-claim { - border: 0; - color: #fff; - background: #ff8b16; -} -.m-mall-search > button { - min-width: 72px; - height: 48px; - border-radius: 25px; - font-size: 17px; +.m-task-mall > .m-page-header { + margin-inline: -2px; } .m-mall-banner { display: block; width: 100%; - margin-top: 18px; + margin: 0 0 12px; border-radius: 8px; } -.m-mall-tabs { - min-height: 62px; - display: flex; - align-items: center; - gap: 24px; -} -.m-mall-tabs button { - min-height: 44px; - padding: 0; - border: 0; - background: transparent; - font-size: 19px; -} -.m-mall-tabs button.active { - color: #ff6d50; - font-weight: 600; -} -.m-mall-tabs > .n-icon { - margin-left: auto; - font-size: 24px; -} .m-mall-list { display: grid; - gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 6px; } .m-mall-task { - min-height: 128px; - padding: 12px; - border-radius: 8px; - background: #fff; - display: grid; - grid-template-columns: 104px minmax(0, 1fr) auto; - gap: 12px; - align-items: center; -} -.m-mall-cover { - width: 104px; - aspect-ratio: 1; - padding: 0; + min-width: 0; + padding: 0 0 11px; border: 0; border-radius: 8px; + background: #fff; + color: var(--m-ink); overflow: hidden; - background: #f0f0f0; - position: relative; + text-align: left; +} +.m-mall-cover { display: grid; + width: 100%; + aspect-ratio: 1; + background: #f0f0f0; place-items: center; } .m-mall-cover img { @@ -3653,45 +3588,22 @@ body { color: #bbb; font-size: 38px; } -.m-mall-cover span { - position: absolute; - right: 0; - bottom: 0; - left: 0; - padding: 4px; - background: rgba(109, 48, 11, 0.88); - color: #fff; - font-size: 12px; -} -.m-mall-task > div { - min-width: 0; -} -.m-mall-task h2 { +.m-mall-task > strong { display: -webkit-box; - margin: 0 0 7px; + min-height: 2.5em; + margin: 8px 10px 3px; overflow: hidden; - font-size: 17px; - line-height: 1.35; + font-size: clamp(15px, 4.3vw, 19px); + font-weight: 600; + line-height: 1.22; -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; } -.m-mall-task strong { +.m-mall-task > small { display: block; - color: #ff684d; - font-size: 21px; -} -.m-mall-task strong small { - font-size: 14px; -} -.m-mall-task p { - margin: 5px 0 0; - color: #b5b5b5; -} -.m-mall-claim { - min-width: 66px; - height: 38px; - border-radius: 20px; - font-size: 16px; + margin: 0 10px; + color: #555; + font-size: 13px; } /* Olons 2 leaderboard */ @@ -3760,10 +3672,18 @@ body { .m-rank-winner > strong, .m-rank-winner > em { max-width: 100%; + font-style: normal; +} +.m-rank-winner > strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-style: normal; +} +.m-rank-winner > em { + overflow: visible; + font-size: clamp(11px, 3vw, 14px); + line-height: 1.2; + overflow-wrap: anywhere; } .m-rank-winner > small { margin-top: 4px; @@ -3781,7 +3701,7 @@ body { .m-rank-table header, .m-rank-row { display: grid; - grid-template-columns: 48px minmax(0, 1.5fr) minmax(82px, 1fr) 70px; + grid-template-columns: 48px minmax(0, 1fr) max-content 70px; align-items: center; gap: 7px; } @@ -3823,6 +3743,7 @@ body { font-style: normal; } .m-rank-row strong { + white-space: nowrap; font-size: 17px; font-weight: 500; } @@ -3849,7 +3770,7 @@ body { .m-home-rank-list a { min-width: 0; display: grid; - grid-template-columns: 30px 34px minmax(0, 1fr) 72px 82px; + grid-template-columns: 30px 34px minmax(0, 1fr) max-content 82px; align-items: center; gap: 7px; color: var(--m-ink); @@ -3895,14 +3816,16 @@ body { .m-home-rank-list strong, .m-home-rank-list em { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } .m-home-rank-list strong { + overflow: hidden; + text-overflow: ellipsis; font-weight: 500; } .m-home-rank-list em { + overflow: visible; + font-size: clamp(12px, 3.2vw, 15px); font-style: normal; text-align: right; font-weight: 600; @@ -4017,36 +3940,19 @@ body { .m-leaderboard { padding-inline: 14px; } - .m-mall-task { - grid-template-columns: 92px minmax(0, 1fr) auto; - gap: 9px; - padding: 10px; - } - .m-mall-cover { - width: 92px; - } - .m-mall-search { - gap: 8px; - } - .m-mall-search h1 { - font-size: 23px; - } - .m-mall-search > button { - min-width: 60px; - } .m-claim-result { padding-inline: 20px; } .m-rank-table header, .m-rank-row { - grid-template-columns: 38px minmax(0, 1.4fr) 72px 58px; + grid-template-columns: 38px minmax(0, 1fr) max-content 58px; gap: 5px; } .m-rank-table { padding-inline: 8px; } .m-home-rank-list a { - grid-template-columns: 26px 32px minmax(0, 1fr) 54px 72px; + grid-template-columns: 26px 32px minmax(0, 1fr) max-content 68px; gap: 5px; } .m-home-rank-list small { diff --git a/web/src/mobile/views/ClaimView.vue b/web/src/mobile/views/ClaimView.vue index 09aa4c3..a0d791c 100644 --- a/web/src/mobile/views/ClaimView.vue +++ b/web/src/mobile/views/ClaimView.vue @@ -5,7 +5,7 @@ 每日参与任务,赚取更多佣金 + 可用余额不足299元

活动规则

    -
  1. 平台运营时间每天 {{ businessHours }}。
  2. -
  3. 每一次储入请先咨询客服获取商家卡号,如有疑问请联系在线客服。
  4. -
  5. 领取后系统将自动分配任务,请按订单要求完成任务内容。
  6. -
  7. 领取时间根据会员等级不同,系统随机匹配订单。
  8. -
  9. 账户可用余额需达到 {{ money(eligibility?.minimum_amount) }} 才可领取。
  10. -
  11. 订单需要在规定时间内完成,请勿超时提交。
  12. +
  13. 平台运营时间每天上午9:00-晚上21:00点!
  14. +
  15. 每一次储入必须咨询客户获取商家卡号!如有不理解可联系在线客服!
  16. +
  17. 领取后系统将自动分配任务,请按订单要求完成任务内容!
  18. +
  19. 领取订单的时间是根据会员等级的不同!系统随机匹配订单!
  20. +
  21. 会员账户必须要拥有299启动资金才可进行领取!
  22. +
  23. 订单需要在规定时间内完成!请勿超时完成商品!
  24. +
  25. 平台硬性条件提现必须信用分值满足100分,请广大会员珍惜账户信用分值!信用分下降必须联系客服处理恢复100分才可正常提现,信用分提升金额具体根据会员等级规定,详细咨询平台客服。
  26. +
  27. 非本人充值资金需要提供支付人身份证信息验证充值资金,避免出现非必要的资金纠纷。
@@ -132,11 +133,12 @@ const eligibleHalls = computed(() => halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible) ); - const businessHours = computed(() => { - const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5); - const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5); - return `${start}-${end}`; - }); + const balanceInsufficient = computed(() => + Object.values(eligibilityByHall.value).some((item: any) => + Number(item?.minimum_amount || 0) > 0 && + Number(item?.available_amount || 0) < Number(item.minimum_amount) + ) + ); const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual'); const matchingHint = computed(() => { if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...'; @@ -202,12 +204,12 @@ if (claiming.value || !halls.value.length) return; claiming.value = true; pollFinished.value = false; + order.value = undefined; try { await loadEligibilities(); const candidates = eligibleHalls.value; if (!candidates.length) { - window.$message.warning(eligibility.value?.message || '暂不满足领取条件'); - return; + throw new Error(eligibility.value?.message || '暂不满足领取条件'); } openMatching(); let stockError: unknown; @@ -348,10 +350,6 @@ closeResult(); router.push('/app/orders'); } - function openPendingOrder() { - const orderID = Number(eligibility.value?.pending_order_id || 0); - router.push(orderID > 0 ? `/app/orders/${orderID}` : '/app/orders'); - } function retryClaim() { closeResult(); void claim(); diff --git a/web/src/mobile/views/HomeView.vue b/web/src/mobile/views/HomeView.vue index a6a42a2..7d2e192 100644 --- a/web/src/mobile/views/HomeView.vue +++ b/web/src/mobile/views/HomeView.vue @@ -74,7 +74,7 @@ {{ entry.username.slice(0, 1) }} {{ entry.username }} - {{ formatCount(entry.completed_count) }} + {{ formatPoints(entry.claimed_points) }}积分 {{ entry.status || '领取成功' }} @@ -139,7 +139,7 @@ rank: number; username: string; avatar_url: string; - completed_count: number; + claimed_points: number; status: string; } const router = useRouter(); @@ -150,7 +150,10 @@ const popupNotice = ref(null); const activeBannerIndex = ref(0); let bannerTimer: number | undefined; - const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0); + const formatPoints = (value: number) => + new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format( + (value || 0) / 100 + ); const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]); const bannerStyle = computed(() => { const url = safeImage(currentBanner.value?.image_url); @@ -180,7 +183,7 @@ enabled: home.value?.features?.withdrawal_enabled === true, }, { - label: '商城', + label: '手工', path: '/app/mall', icon: mobileAssets.shortcutMall, enabled: home.value?.features?.manual_mall_enabled !== false, diff --git a/web/src/mobile/views/LeaderboardView.vue b/web/src/mobile/views/LeaderboardView.vue index 232b86d..de19421 100644 --- a/web/src/mobile/views/LeaderboardView.vue +++ b/web/src/mobile/views/LeaderboardView.vue @@ -22,13 +22,13 @@ {{ entry.rank }} {{ entry.username }} - {{ number(entry.completed_count) }} + {{ points(entry.claimed_points) }} 积分 {{ entry.status }}
-
排名用户昵称领取数量状态
+
排名用户昵称领取积分状态
{{ entry.rank }}
@@ -38,7 +38,7 @@ {{ entry.username }}
- {{ number(entry.completed_count) }} + {{ points(entry.claimed_points) }} {{ entry.status.replace('成功', '') }}
@@ -57,7 +57,7 @@ rank: number; username: string; avatar_url: string; - completed_count: number; + claimed_points: number; status: string; } @@ -66,7 +66,10 @@ const error = ref(''); const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean)); const remaining = computed(() => entries.value.slice(3)); - const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0); + const points = (value: number) => + new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format( + (value || 0) / 100 + ); const podiumBackground = (rank: number) => rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird; diff --git a/web/src/mobile/views/MallView.vue b/web/src/mobile/views/MallView.vue index a0268d0..be95a89 100644 --- a/web/src/mobile/views/MallView.vue +++ b/web/src/mobile/views/MallView.vue @@ -1,40 +1,25 @@