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" /> -