Merge branch 'feat/muyu-subsystem-integration' into 'main'

feat: integrate muyu warehouse subsystem

See merge request kihaneseifu/iloom!1
This commit is contained in:
Chenwei Jiang 2026-07-04 02:34:20 +00:00
commit eafcb9ea88
19 changed files with 604 additions and 92 deletions

View File

@ -19,19 +19,23 @@ variables:
retry: 2
before_script:
- until docker info >/dev/null 2>&1; do echo "Waiting for Docker daemon..."; sleep 2; done
- echo "$HARBOR_PASSWORD" | docker login $HARBOR_REGISTRY -u "$HARBOR_USER" --password-stdin
- echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "${HARBOR_USER}" --password-stdin
script:
- |
if [ -n "$CI_COMMIT_TAG" ]; then
TAG="$CI_COMMIT_TAG"
else
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
TAG="main-${CI_COMMIT_SHORT_SHA}"
else
TAG="feat-${CI_COMMIT_SHORT_SHA}"
fi
- docker build --network host -t $HARBOR_REGISTRY/$HARBOR_PROJECT/$IMAGE_NAME:$TAG -f $DOCKERFILE_PATH .
- docker push $HARBOR_REGISTRY/$HARBOR_PROJECT/$IMAGE_NAME:$TAG
- docker build --network host -t ${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}:${TAG} -f ${DOCKERFILE_PATH} .
- docker push ${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}:${TAG}
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG =~ /^v/
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH =~ /^feat\//
build-gateway:
<<: *build-template

View File

@ -9,6 +9,7 @@ import (
"github.com/muyuqingfeng/iloom/auth-service/internal/handler"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
"github.com/muyuqingfeng/iloom/auth-service/internal/service"
"github.com/muyuqingfeng/iloom/auth-service/internal/storage"
"github.com/muyuqingfeng/iloom/shared/pkg/auth"
"github.com/muyuqingfeng/iloom/shared/pkg/database"
sgrpc "github.com/muyuqingfeng/iloom/shared/pkg/grpc"
@ -32,6 +33,17 @@ func main() {
jwtMgr := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTRefreshSecret)
minioStorage, err := storage.NewMinIOStorage(storage.MinIOConfig{
Endpoint: cfg.MinIOEndpoint,
AccessKey: cfg.MinIOAccessKey,
SecretKey: cfg.MinIOSecretKey,
Bucket: cfg.MinIOBucket,
UseSSL: cfg.MinIOUseSSL,
})
if err != nil {
log.Printf("warning: minio init failed: %v (file upload disabled)", err)
}
profileRepo := repository.NewProfileRepo(db)
companyRepo := repository.NewCompanyRepo(db)
memberRepo := repository.NewMemberRepo(db)
@ -48,7 +60,7 @@ func main() {
companyH := handler.NewCompanyHandler(companySvc)
memberH := handler.NewMemberHandler(memberSvc)
notifH := handler.NewNotificationHandler(notifSvc)
commonH := handler.NewCommonHandler(shareLinkRepo, auditLogRepo)
commonH := handler.NewCommonHandler(shareLinkRepo, auditLogRepo, minioStorage)
r := gin.New()
r.Use(
@ -83,6 +95,7 @@ func main() {
commonGroup.GET("/share-links/:code", commonH.GetShareLink)
commonGroup.POST("/feedback", commonH.Feedback)
commonGroup.POST("/crash-reports", commonH.CrashReport)
commonGroup.POST("/upload", commonH.Upload)
}
addr := ":" + cfg.Port

View File

@ -11,6 +11,7 @@ replace github.com/muyuqingfeng/iloom/shared => ../shared
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.0.82
)
require (
@ -19,21 +20,26 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect

View File

@ -11,12 +11,16 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
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-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -31,8 +35,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@ -44,14 +48,21 @@ 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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/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.82 h1:tWfICLhmp2aFPXL8Tli0XDTHj2VB/fNf0PC1f/i1gRo=
github.com/minio/minio-go/v7 v7.0.82/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -61,6 +72,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

View File

@ -8,6 +8,11 @@ type Config struct {
JWTSecret string
JWTRefreshSecret string
SystemRPCAddr string
MinIOEndpoint string
MinIOAccessKey string
MinIOSecretKey string
MinIOBucket string
MinIOUseSSL bool
}
func Load() *Config {
@ -17,6 +22,11 @@ func Load() *Config {
JWTSecret: getEnv("JWT_SECRET", ""),
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", ""),
SystemRPCAddr: getEnv("MUYU_SYSTEM_RPC_ADDR", "127.0.0.1:9001"),
MinIOEndpoint: getEnv("MINIO_ENDPOINT", "minio:9000"),
MinIOAccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
MinIOSecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin123"),
MinIOBucket: getEnv("MINIO_BUCKET", "iloom-uploads"),
MinIOUseSSL: getEnv("MINIO_USE_SSL", "false") == "true",
}
}

View File

@ -1,20 +1,24 @@
package handler
import (
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
"github.com/muyuqingfeng/iloom/auth-service/internal/storage"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type CommonHandler struct {
shareLinkRepo *repository.ShareLinkRepo
auditLogRepo *repository.AuditLogRepo
storage *storage.MinIOStorage
}
func NewCommonHandler(slRepo *repository.ShareLinkRepo, alRepo *repository.AuditLogRepo) *CommonHandler {
return &CommonHandler{shareLinkRepo: slRepo, auditLogRepo: alRepo}
func NewCommonHandler(slRepo *repository.ShareLinkRepo, alRepo *repository.AuditLogRepo, st *storage.MinIOStorage) *CommonHandler {
return &CommonHandler{shareLinkRepo: slRepo, auditLogRepo: alRepo, storage: st}
}
type createShareLinkReq struct {
@ -132,3 +136,48 @@ func (h *CommonHandler) CrashReport(c *gin.Context) {
response.OK(c, gin.H{"message": "crash report received"})
}
const maxUploadSize = 10 << 20 // 10 MB
func (h *CommonHandler) Upload(c *gin.Context) {
if h.storage == nil {
response.InternalError(c, "file storage not configured")
return
}
if err := c.Request.ParseMultipartForm(maxUploadSize); err != nil {
response.BadRequest(c, fmt.Sprintf("file too large (max %dMB)", maxUploadSize>>20))
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "missing file field")
return
}
defer file.Close()
// Validate content type
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
response.BadRequest(c, "only image files are allowed")
return
}
// Validate size
if header.Size > maxUploadSize {
response.BadRequest(c, fmt.Sprintf("file too large (max %dMB)", maxUploadSize>>20))
return
}
pathPrefix := c.DefaultPostForm("path_prefix", "products")
objectPath, err := h.storage.Upload(c.Request.Context(), pathPrefix, header.Filename, file, header.Size, contentType)
if err != nil {
response.InternalError(c, "upload failed: "+err.Error())
return
}
url := "/uploads/" + objectPath
response.OK(c, gin.H{"url": url})
}

View File

@ -0,0 +1,101 @@
package storage
import (
"context"
"fmt"
"io"
"log"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type MinIOStorage struct {
client *minio.Client
bucket string
}
type MinIOConfig struct {
Endpoint string
AccessKey string
SecretKey string
Bucket string
UseSSL bool
}
func NewMinIOStorage(cfg MinIOConfig) (*MinIOStorage, error) {
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("create minio client: %w", err)
}
ctx := context.Background()
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("check bucket: %w", err)
}
if !exists {
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
return nil, fmt.Errorf("create bucket: %w", err)
}
log.Printf("created minio bucket: %s", cfg.Bucket)
// Set bucket policy to allow public read
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}]
}`, cfg.Bucket)
if err := client.SetBucketPolicy(ctx, cfg.Bucket, policy); err != nil {
log.Printf("warning: set bucket policy: %v", err)
}
}
return &MinIOStorage{client: client, bucket: cfg.Bucket}, nil
}
// Upload stores a file in MinIO and returns the object path (without bucket prefix).
// The returned path can be used to construct the public URL: /uploads/{path}
func (s *MinIOStorage) Upload(ctx context.Context, pathPrefix string, filename string, reader io.Reader, size int64, contentType string) (string, error) {
ext := filepath.Ext(filename)
if ext == "" {
ext = guessExt(contentType)
}
objectName := fmt.Sprintf("%s/%s%s", pathPrefix, uuid.New().String(), ext)
_, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return "", fmt.Errorf("put object: %w", err)
}
return objectName, nil
}
func guessExt(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/jpeg"):
return ".jpg"
case strings.HasPrefix(contentType, "image/png"):
return ".png"
case strings.HasPrefix(contentType, "image/gif"):
return ".gif"
case strings.HasPrefix(contentType, "image/webp"):
return ".webp"
case strings.HasPrefix(contentType, "image/svg"):
return ".svg"
default:
return ".bin"
}
}

View File

@ -29,8 +29,17 @@ func main() {
v1 := r.Group("/api/v1")
{
// /api/v1/auth/* -> AUTH_SERVICE_URL (no auth required)
v1.Any("/auth/*path", proxy.NewReverseProxy(cfg.AuthServiceURL))
// /api/v1/auth/* -> AUTH_SERVICE_URL
// Public auth endpoints (no JWT required)
authPublic := v1.Group("/auth")
authPublic.POST("/register", proxy.NewReverseProxy(cfg.AuthServiceURL))
authPublic.POST("/login", proxy.NewReverseProxy(cfg.AuthServiceURL))
authPublic.POST("/logout", proxy.NewReverseProxy(cfg.AuthServiceURL))
authPublic.POST("/refresh", proxy.NewReverseProxy(cfg.AuthServiceURL))
// Protected auth endpoints (JWT required to set X-User-ID)
authProtected := v1.Group("/auth", middleware.AuthRequired(cfg.JWTSecret))
authProtected.GET("/me", proxy.NewReverseProxy(cfg.AuthServiceURL))
// /api/v1/common/* -> AUTH_SERVICE_URL (JWT required)
common := v1.Group("/common", middleware.AuthRequired(cfg.JWTSecret))
@ -56,6 +65,13 @@ func main() {
middleware.RoleRequired("washing"),
)
washing.Any("/*path", proxy.NewReverseProxy(cfg.WashingServiceURL))
// /api/v1/muyu/* -> MUYU_GATEWAY_URL (muyu warehouse subsystem)
// muyu-gateway handles its own authentication internally
muyu := v1.Group("/muyu",
middleware.AuthRequired(cfg.JWTSecret),
)
muyu.Any("/*path", proxy.NewReverseProxy(cfg.MuyuGatewayURL))
}
// WebSocket proxy — route by role (JWT + role check applied)

View File

@ -12,6 +12,7 @@ type Config struct {
PurchaserServiceURL string
TextileServiceURL string
WashingServiceURL string
MuyuGatewayURL string
AllowOrigins []string
}
@ -23,6 +24,7 @@ func Load() *Config {
PurchaserServiceURL: getEnv("PURCHASER_SERVICE_URL", "http://localhost:8082"),
TextileServiceURL: getEnv("TEXTILE_SERVICE_URL", "http://localhost:8083"),
WashingServiceURL: getEnv("WASHING_SERVICE_URL", "http://localhost:8084"),
MuyuGatewayURL: getEnv("MUYU_GATEWAY_URL", "http://localhost:8888"),
AllowOrigins: strings.Split(getEnv("ALLOW_ORIGINS", "*"), ","),
}
}

View File

@ -21,6 +21,17 @@ data:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 10m;
}
location ^~ /uploads/ {
resolver 10.43.0.10 valid=30s;
set $minio_host "minio.iloom.svc.cluster.local";
rewrite ^/uploads/(.*)$ /iloom-uploads/$1 break;
proxy_pass http://$minio_host:9000;
proxy_set_header Host $minio_host:9000;
expires 7d;
add_header Cache-Control "public";
}
location /ws/ {
@ -33,12 +44,23 @@ data:
proxy_read_timeout 86400;
}
# SPA fallback — index.html must never be cached
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Hashed static assets (e.g. main.decafe71.js) — safe to cache long-term
location ~* \.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(js|css)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Other static assets (images, fonts) — moderate cache
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public";
}
}

97
k8s/10-infra/minio.yaml Normal file
View File

@ -0,0 +1,97 @@
apiVersion: v1
kind: Secret
metadata:
name: minio-secret
namespace: iloom
type: Opaque
stringData:
MINIO_ROOT_USER: "minioadmin"
MINIO_ROOT_PASSWORD: "minioadmin123"
---
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: iloom
spec:
clusterIP: None
ports:
- name: api
port: 9000
targetPort: 9000
- name: console
port: 9001
targetPort: 9001
selector:
app: minio
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: minio
namespace: iloom
spec:
serviceName: minio
replicas: 1
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
imagePullSecrets:
- name: harbor-pull-secret
containers:
- name: minio
image: harbor-in-k3s.cheverjohn.me/iloom/minio:latest
args:
- server
- /data
- --console-address
- ":9001"
ports:
- containerPort: 9000
- containerPort: 9001
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: minio-secret
key: MINIO_ROOT_USER
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: minio-secret
key: MINIO_ROOT_PASSWORD
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
httpGet:
path: /minio/health/ready
port: 9000
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /minio/health/live
port: 9000
initialDelaySeconds: 20
periodSeconds: 30
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 2Gi

View File

@ -60,6 +60,20 @@ spec:
key: JWT_REFRESH_SECRET
- name: MUYU_SYSTEM_RPC_ADDR
value: "muyu-system-rpc:9001"
- name: MINIO_ENDPOINT
value: "minio:9000"
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: minio-secret
key: MINIO_ROOT_USER
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: minio-secret
key: MINIO_ROOT_PASSWORD
- name: MINIO_BUCKET
value: "iloom-uploads"
resources:
requests:
cpu: 50m

View File

@ -20,15 +20,26 @@ func NewProductHandler(svc *service.ProductService, wsHub *websocket.Hub) *Produ
}
type CreateProductReq struct {
ProductName string `json:"product_name" binding:"required"`
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code" binding:"required"`
Color string `json:"color" binding:"required"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
WarpWeight *float64 `json:"warp_weight"`
WeftWeight *float64 `json:"weft_weight"`
TotalWeight *float64 `json:"total_weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
ImageURL *string `json:"image_url"`
ProductType string `json:"product_type"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
YarnRatios []YarnRatioReq `json:"yarn_ratios"`
}
type YarnRatioReq struct {
YarnName string `json:"yarn_name"`
YarnType string `json:"yarn_type"`
Ratio float64 `json:"ratio"`
}
type UpdateProductReq struct {
@ -37,10 +48,15 @@ type UpdateProductReq struct {
Color string `json:"color"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
WarpWeight *float64 `json:"warp_weight"`
WeftWeight *float64 `json:"weft_weight"`
TotalWeight *float64 `json:"total_weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
ImageURL *string `json:"image_url"`
ProductType string `json:"product_type"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
YarnRatios []YarnRatioReq `json:"yarn_ratios"`
}
type InboundReq struct {
@ -97,16 +113,30 @@ func (h *ProductHandler) Create(c *gin.Context) {
return
}
yarnRatios := make([]service.YarnRatio, len(req.YarnRatios))
for i, yr := range req.YarnRatios {
yarnRatios[i] = service.YarnRatio{
YarnName: yr.YarnName,
YarnType: yr.YarnType,
Ratio: yr.Ratio,
}
}
svcReq := service.CreateProductRequest{
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
WarpWeight: req.WarpWeight,
WeftWeight: req.WeftWeight,
TotalWeight: req.TotalWeight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
ImageURL: req.ImageURL,
ProductType: req.ProductType,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
YarnRatios: yarnRatios,
}
product, err := h.svc.Create(c.Request.Context(), companyID, svcReq)
@ -143,16 +173,30 @@ func (h *ProductHandler) Update(c *gin.Context) {
return
}
yarnRatios := make([]service.YarnRatio, len(req.YarnRatios))
for i, yr := range req.YarnRatios {
yarnRatios[i] = service.YarnRatio{
YarnName: yr.YarnName,
YarnType: yr.YarnType,
Ratio: yr.Ratio,
}
}
svcReq := service.UpdateProductRequest{
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
WarpWeight: req.WarpWeight,
WeftWeight: req.WeftWeight,
TotalWeight: req.TotalWeight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
ImageURL: req.ImageURL,
ProductType: req.ProductType,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
YarnRatios: yarnRatios,
}
if err := h.svc.Update(c.Request.Context(), id, companyID, svcReq); err != nil {

View File

@ -1,6 +1,71 @@
package model
import "time"
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
type ProductYarnRatio struct {
YarnName string `json:"yarn_name"`
YarnType string `json:"yarn_type"`
Ratio float64 `json:"ratio"`
}
type JSONStringSlice []string
func (s *JSONStringSlice) Scan(src interface{}) error {
if src == nil {
*s = nil
return nil
}
var b []byte
switch v := src.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return fmt.Errorf("unsupported type: %T", src)
}
return json.Unmarshal(b, s)
}
func (s JSONStringSlice) Value() (driver.Value, error) {
if s == nil {
return nil, nil
}
b, err := json.Marshal(s)
return string(b), err
}
type JSONYarnRatios []ProductYarnRatio
func (r *JSONYarnRatios) Scan(src interface{}) error {
if src == nil {
*r = nil
return nil
}
var b []byte
switch v := src.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return fmt.Errorf("unsupported type: %T", src)
}
return json.Unmarshal(b, r)
}
func (r JSONYarnRatios) Value() (driver.Value, error) {
if r == nil {
return nil, nil
}
b, err := json.Marshal(r)
return string(b), err
}
type Product struct {
ID string `json:"id" db:"id"`
@ -19,8 +84,9 @@ type Product struct {
RawFabricMeters float64 `json:"raw_fabric_meters" db:"raw_fabric_meters"`
RawFabricRolls int `json:"raw_fabric_rolls" db:"raw_fabric_rolls"`
ImageURL *string `json:"image_url,omitempty" db:"image_url"`
YarnTypes []string `json:"yarn_types,omitempty" db:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios,omitempty" db:"yarn_ratios"`
ProductType string `json:"product_type" db:"product_type"`
YarnTypes JSONStringSlice `json:"yarn_types,omitempty" db:"yarn_types"`
YarnRatios JSONYarnRatios `json:"yarn_ratios,omitempty" db:"yarn_ratios"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

View File

@ -282,8 +282,8 @@ func (r *PlanRepo) GetInventory(ctx context.Context, planID string) ([]model.Inv
func (r *PlanRepo) CountByStatus(ctx context.Context, companyID string) (total, producing, completed int, err error) {
query := `SELECT
COUNT(*),
SUM(CASE WHEN status = 'producing' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)
COALESCE(SUM(CASE WHEN status = 'producing' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
FROM ilm_production_plan WHERE purchaser_id = ?`
err = r.db.QueryRowContext(ctx, query, companyID).Scan(&total, &producing, &completed)
if err != nil {

View File

@ -22,7 +22,7 @@ func (r *ProductRepo) List(ctx context.Context, companyID string) ([]model.Produ
query := `SELECT id, company_id, product_name, fabric_code, color, color_code,
weight, warp_weight, weft_weight, total_weight, yarn_usage_per_meter,
production_price, total_stock, raw_fabric_meters, raw_fabric_rolls,
image_url, yarn_types, yarn_ratios, created_at, updated_at
image_url, product_type, yarn_types, yarn_ratios, created_at, updated_at
FROM products WHERE company_id = ? ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, companyID)
@ -39,7 +39,7 @@ func (r *ProductRepo) List(ctx context.Context, companyID string) ([]model.Produ
&p.Weight, &p.WarpWeight, &p.WeftWeight, &p.TotalWeight,
&p.YarnUsagePerMeter, &p.ProductionPrice,
&p.TotalStock, &p.RawFabricMeters, &p.RawFabricRolls,
&p.ImageURL, &p.YarnTypes, &p.YarnRatios,
&p.ImageURL, &p.ProductType, &p.YarnTypes, &p.YarnRatios,
&p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan product: %w", err)
@ -54,14 +54,14 @@ func (r *ProductRepo) Create(ctx context.Context, p *model.Product) error {
(id, company_id, product_name, fabric_code, color, color_code,
weight, warp_weight, weft_weight, total_weight, yarn_usage_per_meter,
production_price, total_stock, raw_fabric_meters, raw_fabric_rolls,
image_url, yarn_types, yarn_ratios, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
image_url, product_type, yarn_types, yarn_ratios, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
_, err := r.db.ExecContext(ctx, query,
p.ID, p.CompanyID, p.ProductName, p.FabricCode, p.Color, p.ColorCode,
p.Weight, p.WarpWeight, p.WeftWeight, p.TotalWeight,
p.YarnUsagePerMeter, p.ProductionPrice,
p.TotalStock, p.RawFabricMeters, p.RawFabricRolls,
p.ImageURL, p.YarnTypes, p.YarnRatios,
p.ImageURL, p.ProductType, p.YarnTypes, p.YarnRatios,
p.CreatedAt, p.UpdatedAt,
)
if err != nil {
@ -75,13 +75,13 @@ func (r *ProductRepo) Update(ctx context.Context, p *model.Product) error {
product_name = ?, fabric_code = ?, color = ?, color_code = ?,
weight = ?, warp_weight = ?, weft_weight = ?, total_weight = ?,
yarn_usage_per_meter = ?, production_price = ?,
image_url = ?, yarn_types = ?, yarn_ratios = ?, updated_at = ?
image_url = ?, product_type = ?, yarn_types = ?, yarn_ratios = ?, updated_at = ?
WHERE id = ?`
_, err := r.db.ExecContext(ctx, query,
p.ProductName, p.FabricCode, p.Color, p.ColorCode,
p.Weight, p.WarpWeight, p.WeftWeight, p.TotalWeight,
p.YarnUsagePerMeter, p.ProductionPrice,
p.ImageURL, p.YarnTypes, p.YarnRatios, time.Now(),
p.ImageURL, p.ProductType, p.YarnTypes, p.YarnRatios, time.Now(),
p.ID,
)
if err != nil {

View File

@ -20,16 +20,27 @@ func NewProductService(repo *repository.ProductRepo, db *sql.DB) *ProductService
return &ProductService{repo: repo, db: db}
}
type YarnRatio struct {
YarnName string `json:"yarn_name"`
YarnType string `json:"yarn_type"`
Ratio float64 `json:"ratio"`
}
type CreateProductRequest struct {
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
WarpWeight *float64 `json:"warp_weight"`
WeftWeight *float64 `json:"weft_weight"`
TotalWeight *float64 `json:"total_weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
ImageURL *string `json:"image_url"`
ProductType string `json:"product_type"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
YarnRatios []YarnRatio `json:"yarn_ratios"`
}
type UpdateProductRequest struct {
@ -38,10 +49,15 @@ type UpdateProductRequest struct {
Color string `json:"color"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
WarpWeight *float64 `json:"warp_weight"`
WeftWeight *float64 `json:"weft_weight"`
TotalWeight *float64 `json:"total_weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
ImageURL *string `json:"image_url"`
ProductType string `json:"product_type"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
YarnRatios []YarnRatio `json:"yarn_ratios"`
}
type InboundRequest struct {
@ -70,6 +86,21 @@ func (s *ProductService) List(ctx context.Context, companyID string) ([]model.Pr
func (s *ProductService) Create(ctx context.Context, companyID string, req CreateProductRequest) (*model.Product, error) {
now := time.Now()
yarnRatios := make(model.JSONYarnRatios, len(req.YarnRatios))
for i, yr := range req.YarnRatios {
yarnRatios[i] = model.ProductYarnRatio{
YarnName: yr.YarnName,
YarnType: yr.YarnType,
Ratio: yr.Ratio,
}
}
productType := req.ProductType
if productType == "" {
productType = "raw_fabric"
}
p := &model.Product{
ID: uuid.New().String(),
CompanyID: companyID,
@ -78,13 +109,18 @@ func (s *ProductService) Create(ctx context.Context, companyID string, req Creat
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
WarpWeight: req.WarpWeight,
WeftWeight: req.WeftWeight,
TotalWeight: req.TotalWeight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
ImageURL: req.ImageURL,
ProductType: productType,
TotalStock: 0,
RawFabricMeters: 0,
RawFabricRolls: 0,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
YarnTypes: model.JSONStringSlice(req.YarnTypes),
YarnRatios: yarnRatios,
CreatedAt: now,
UpdatedAt: now,
}
@ -96,6 +132,20 @@ func (s *ProductService) Create(ctx context.Context, companyID string, req Creat
}
func (s *ProductService) Update(ctx context.Context, id, companyID string, req UpdateProductRequest) error {
yarnRatios := make(model.JSONYarnRatios, len(req.YarnRatios))
for i, yr := range req.YarnRatios {
yarnRatios[i] = model.ProductYarnRatio{
YarnName: yr.YarnName,
YarnType: yr.YarnType,
Ratio: yr.Ratio,
}
}
productType := req.ProductType
if productType == "" {
productType = "raw_fabric"
}
p := &model.Product{
ID: id,
ProductName: req.ProductName,
@ -103,10 +153,15 @@ func (s *ProductService) Update(ctx context.Context, id, companyID string, req U
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
WarpWeight: req.WarpWeight,
WeftWeight: req.WeftWeight,
TotalWeight: req.TotalWeight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
ImageURL: req.ImageURL,
ProductType: productType,
YarnTypes: model.JSONStringSlice(req.YarnTypes),
YarnRatios: yarnRatios,
}
return s.repo.Update(ctx, p)
}

View File

@ -91,8 +91,8 @@ func (r *PlanRepo) CountByFactory(ctx context.Context, factoryCompanyID string)
query := `
SELECT
COUNT(*),
SUM(CASE WHEN pp.status IN ('pending', 'in_progress') THEN 1 ELSE 0 END),
SUM(CASE WHEN pp.status = 'completed' THEN 1 ELSE 0 END)
COALESCE(SUM(CASE WHEN pp.status IN ('pending', 'in_progress') THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN pp.status = 'completed' THEN 1 ELSE 0 END), 0)
FROM ilm_production_plan pp
JOIN ilm_plan_factory pf ON pp.id = pf.plan_id
WHERE pf.factory_id = ? AND pf.factory_type = 'textile'`

View File

@ -140,8 +140,8 @@ func (r *PlanRepo) Completed(ctx context.Context, companyID string) ([]model.Was
func (r *PlanRepo) CountByStatus(ctx context.Context, companyID string) (total, pending, completed int, err error) {
query := `SELECT
COUNT(*),
SUM(CASE WHEN status IN ('pending', 'washing') THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)
COALESCE(SUM(CASE WHEN status IN ('pending', 'washing') THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
FROM ilm_washing_plan WHERE washing_factory_id = ?`
err = r.db.QueryRowContext(ctx, query, companyID).Scan(&total, &pending, &completed)
if err != nil {