feat: add object storage and points leaderboard
This commit is contained in:
+23
-2
@@ -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 {
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -153,15 +153,15 @@ func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
|
||||
|
||||
func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) {
|
||||
service := New(fakeRepository{leaders: []store.LeaderboardMember{
|
||||
{MemberID: 7, Username: "风轻云淡", CompletedCount: 12},
|
||||
{MemberID: 8, Username: "李明", CompletedCount: 9},
|
||||
{MemberID: 7, Username: "风轻云淡", ClaimedPoints: 125800},
|
||||
{MemberID: 8, Username: "李明", ClaimedPoints: 96000},
|
||||
}})
|
||||
items, err := service.Leaderboard(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" ||
|
||||
items[1].Username != "李*" || items[1].Status != "领取成功" {
|
||||
items[0].ClaimedPoints != 125800 || items[1].Username != "李*" || items[1].Status != "领取成功" {
|
||||
t.Fatalf("unexpected leaderboard: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user