Compare commits

...

10 Commits

Author SHA1 Message Date
kae
028bb6cc0e edit dockerfiles for compatible deploy 2026-07-22 12:35:21 +09:00
Chenwei Jiang
eafcb9ea88 Merge branch 'feat/muyu-subsystem-integration' into 'main'
feat: integrate muyu warehouse subsystem

See merge request kihaneseifu/iloom!1
2026-07-04 02:34:20 +00:00
Chenwei Jiang
6398e7d0e9 feat: integrate muyu warehouse subsystem 2026-07-04 02:34:20 +00:00
Chever John
24c449ecae
security: remove hardcoded credentials and add WebSocket auth/CORS
- Clear default passwords in all service configs, require env vars
- Add JWT auth + role middleware to WebSocket endpoints
- Add origin whitelist to WebSocket upgrader (CORS protection)
- Fix goroutine leak in WS proxy (double errCh read)
- Update docker-compose to require secrets via ${VAR:?...} syntax
- Mark deprecated k8s configmap passwords as REPLACE_AT_DEPLOY_TIME

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 23:16:17 +08:00
Chever John
08b6c7cbeb
fix(ci): use host networking and custom DNS for DinD builds
DinD containers can't reach external services due to DNS/networking
issues. Fix by:
- Passing --dns 10.43.0.10 (CoreDNS) and --dns 223.5.5.5 (Alibaba)
  to DinD daemon
- Using --network host for docker build to use pod networking

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:28:16 +08:00
Chever John
9e25c799d5
fix: use Aliyun Alpine mirror in Dockerfiles
Alpine CDN (dl-cdn.alpinelinux.org) unreachable from DinD containers.
Switch to mirrors.aliyun.com for apk package downloads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:24:59 +08:00
Chever John
75f8dca36b
fix(ci): stagger builds in stages and add retry for Harbor load
Split 5 parallel build jobs into 3 stages (2+2+1) to avoid
overwhelming Harbor with concurrent image pulls. Add retry: 2
for transient failures.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:20:08 +08:00
Chever John
475589b23f
fix: use Harbor base images and Chinese Go proxy for CI builds
Docker Hub and proxy.golang.org are unreachable from k3s cluster
(GFW). Switch all Dockerfiles to pull base images from Harbor
(harbor-in-k3s.cheverjohn.me/library/) and use goproxy.cn for
Go module downloads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:17:36 +08:00
Chever John
6568d33c02
fix(ci): add Docker daemon wait and TLS env vars for DinD
Set DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH for k8s runner
DinD sidecar communication. Add 'until docker info' wait loop to
ensure daemon is ready before build starts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:10:56 +08:00
Chever John
7226dfd394
feat: add GitLab CI pipeline for multi-service Docker build
Build all 5 iloom services (gateway, auth, purchaser, textile, washing)
as Docker images and push to Harbor registry.
- main branch: tagged as main-<sha>
- git tag v*: tagged with version number

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:01:47 +08:00
34 changed files with 946 additions and 147 deletions

View File

@ -1,18 +1,20 @@
# iloom 微服务环境变量配置
# Copy this file to .env and fill in real values. NEVER commit .env to git.
# MySQL (共享 muyu_wms 数据库)
DB_DSN=root:muyu2026@tcp(mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4
MYSQL_ROOT_PASSWORD=your_mysql_password_here
DB_DSN=root:your_password@tcp(mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4
# JWT (复用 muyu 认证)
JWT_SECRET=muyu-wms-jwt-secret-key-2026
JWT_REFRESH_SECRET=muyu-wms-jwt-refresh-2026
JWT_SECRET=your_jwt_secret_here
JWT_REFRESH_SECRET=your_jwt_refresh_secret_here
# muyu gRPC 地址
MUYU_SYSTEM_RPC_ADDR=system-rpc:9001
MUYU_INVENTORY_RPC_ADDR=inventory-rpc:9002
# 服务间调用密钥
INTERNAL_SERVICE_KEY=internal-service-shared-secret
INTERNAL_SERVICE_KEY=your_internal_service_key_here
# 服务端口
GATEWAY_PORT=8080

73
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,73 @@
stages:
- build-1
- build-2
- build-3
variables:
HARBOR_REGISTRY: harbor-in-k3s.cheverjohn.me
HARBOR_PROJECT: iloom
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://localhost:2376
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /certs/client
.build-template: &build-template
image: docker:27
services:
- name: docker:27-dind
command: ["--dns", "10.43.0.10", "--dns", "223.5.5.5"]
retry: 2
before_script:
- until docker info >/dev/null 2>&1; do echo "Waiting for Docker daemon..."; sleep 2; done
- echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "${HARBOR_USER}" --password-stdin
script:
- |
if [ -n "$CI_COMMIT_TAG" ]; then
TAG="$CI_COMMIT_TAG"
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}
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
stage: build-1
variables:
IMAGE_NAME: iloom-gateway
DOCKERFILE_PATH: gateway/Dockerfile
build-auth-service:
<<: *build-template
stage: build-1
variables:
IMAGE_NAME: iloom-auth-service
DOCKERFILE_PATH: auth-service/Dockerfile
build-purchaser-service:
<<: *build-template
stage: build-2
variables:
IMAGE_NAME: iloom-purchaser-service
DOCKERFILE_PATH: purchaser-service/Dockerfile
build-textile-service:
<<: *build-template
stage: build-2
variables:
IMAGE_NAME: iloom-textile-service
DOCKERFILE_PATH: textile-service/Dockerfile
build-washing-service:
<<: *build-template
stage: build-3
variables:
IMAGE_NAME: iloom-washing-service
DOCKERFILE_PATH: washing-service/Dockerfile

View File

@ -1,11 +1,16 @@
FROM golang:1.24-alpine AS builder
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library/
FROM ${BASE_REGISTRY}golang:1.24-alpine AS builder
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY shared/ ./shared/
COPY auth-service/ ./auth-service/
WORKDIR /app/auth-service
RUN go build -o /auth-service ./cmd/
FROM alpine:3.20
FROM ${BASE_REGISTRY}alpine:3.20
ARG APK_MIRROR=mirrors.aliyun.com
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi
RUN apk add --no-cache wget
COPY --from=builder /auth-service /auth-service
EXPOSE 8081

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,15 +8,25 @@ type Config struct {
JWTSecret string
JWTRefreshSecret string
SystemRPCAddr string
MinIOEndpoint string
MinIOAccessKey string
MinIOSecretKey string
MinIOBucket string
MinIOUseSSL bool
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8081"),
DBDSN: getEnv("DB_DSN", "root:muyu2026@tcp(127.0.0.1:3306)/muyu_wms?parseTime=true&charset=utf8mb4"),
JWTSecret: getEnv("JWT_SECRET", "muyu-wms-jwt-secret-key-2026"),
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "muyu-wms-jwt-refresh-2026"),
DBDSN: getEnv("DB_DSN", ""),
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

@ -6,11 +6,15 @@ services:
build:
context: .
dockerfile: gateway/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${GATEWAY_PORT:-8080}:8080"
environment:
- PORT=8080
- JWT_SECRET=${JWT_SECRET:-muyu-wms-jwt-secret-key-2026}
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}
- AUTH_SERVICE_URL=http://auth-service:8081
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
- TEXTILE_SERVICE_URL=http://textile-service:8083
@ -33,13 +37,17 @@ services:
build:
context: .
dockerfile: auth-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${AUTH_SERVICE_PORT:-8081}:8081"
environment:
- PORT=8081
- DB_DSN=root:muyu2026@tcp(muyu-mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4
- JWT_SECRET=${JWT_SECRET:-muyu-wms-jwt-secret-key-2026}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-muyu-wms-jwt-refresh-2026}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET in .env}
- MUYU_SYSTEM_RPC_ADDR=muyu-system-rpc:9001
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/health"]
@ -54,12 +62,16 @@ services:
build:
context: .
dockerfile: purchaser-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${PURCHASER_SERVICE_PORT:-8082}:8082"
environment:
- PORT=8082
- DB_DSN=root:muyu2026@tcp(muyu-mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:-internal-service-shared-secret}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:?Set INTERNAL_SERVICE_KEY in .env}
- MUYU_INVENTORY_RPC_ADDR=muyu-inventory-rpc:9002
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8082/health"]
@ -74,12 +86,16 @@ services:
build:
context: .
dockerfile: textile-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${TEXTILE_SERVICE_PORT:-8083}:8083"
environment:
- PORT=8083
- DB_DSN=root:muyu2026@tcp(muyu-mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:-internal-service-shared-secret}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:?Set INTERNAL_SERVICE_KEY in .env}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8083/health"]
@ -94,12 +110,16 @@ services:
build:
context: .
dockerfile: washing-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${WASHING_SERVICE_PORT:-8084}:8084"
environment:
- PORT=8084
- DB_DSN=root:muyu2026@tcp(muyu-mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:-internal-service-shared-secret}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:?Set INTERNAL_SERVICE_KEY in .env}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8084/health"]
@ -112,8 +132,10 @@ services:
frontend:
build:
context: .
dockerfile: iloom-flatten/Dockerfile
context: ./iloom-flatten
dockerfile: Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
ports:
- "${FRONTEND_PORT:-3015}:3015"
depends_on:

View File

@ -6,7 +6,7 @@
# 集成模式(与 muyu-apiserver 联合):
# 1. cd ../muyu-apiserver/deploy && docker compose up
# 2. 设置环境变量指向 muyu 的服务:
# export DB_DSN="root:muyu2026@tcp(muyu-mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4"
# export DB_DSN="root:yourpassword@tcp(muyu-mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4"
# export MUYU_SYSTEM_RPC_ADDR=muyu-system-rpc:9001
# export MUYU_INVENTORY_RPC_ADDR=muyu-inventory-rpc:9002
# 3. docker compose --profile integrated up
@ -15,7 +15,7 @@ services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-muyu2026}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env}
MYSQL_DATABASE: ${MYSQL_DATABASE:-muyu_wms}
TZ: Asia/Shanghai
ports:
@ -26,7 +26,7 @@ services:
- ./scripts/init-iloom.sql:/docker-entrypoint-initdb.d/02-iloom.sql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-muyu2026}"]
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 10
@ -35,11 +35,15 @@ services:
build:
context: .
dockerfile: gateway/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${GATEWAY_PORT:-8080}:8080"
environment:
- PORT=8080
- JWT_SECRET=${JWT_SECRET:-muyu-wms-jwt-secret-key-2026}
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}
- AUTH_SERVICE_URL=http://auth-service:8081
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
- TEXTILE_SERVICE_URL=http://textile-service:8083
@ -59,13 +63,17 @@ services:
build:
context: .
dockerfile: auth-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${AUTH_SERVICE_PORT:-8081}:8081"
environment:
- PORT=8081
- DB_DSN=${DB_DSN:-root:muyu2026@tcp(mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4}
- JWT_SECRET=${JWT_SECRET:-muyu-wms-jwt-secret-key-2026}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-muyu-wms-jwt-refresh-2026}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET in .env}
- MUYU_SYSTEM_RPC_ADDR=${MUYU_SYSTEM_RPC_ADDR:-system-rpc:9001}
depends_on:
mysql:
@ -80,12 +88,16 @@ services:
build:
context: .
dockerfile: purchaser-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${PURCHASER_SERVICE_PORT:-8082}:8082"
environment:
- PORT=8082
- DB_DSN=${DB_DSN:-root:muyu2026@tcp(mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:-internal-service-shared-secret}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:?Set INTERNAL_SERVICE_KEY in .env}
- MUYU_INVENTORY_RPC_ADDR=${MUYU_INVENTORY_RPC_ADDR:-inventory-rpc:9002}
depends_on:
mysql:
@ -100,12 +112,16 @@ services:
build:
context: .
dockerfile: textile-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${TEXTILE_SERVICE_PORT:-8083}:8083"
environment:
- PORT=8083
- DB_DSN=${DB_DSN:-root:muyu2026@tcp(mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:-internal-service-shared-secret}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:?Set INTERNAL_SERVICE_KEY in .env}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
depends_on:
mysql:
@ -120,12 +136,16 @@ services:
build:
context: .
dockerfile: washing-service/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
GOPROXY: ${GOPROXY-https://goproxy.cn,direct}
APK_MIRROR: ${APK_MIRROR-mirrors.aliyun.com}
ports:
- "${WASHING_SERVICE_PORT:-8084}:8084"
environment:
- PORT=8084
- DB_DSN=${DB_DSN:-root:muyu2026@tcp(mysql:3306)/muyu_wms?parseTime=true&charset=utf8mb4}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:-internal-service-shared-secret}
- DB_DSN=${DB_DSN:?Set DB_DSN in .env}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY:?Set INTERNAL_SERVICE_KEY in .env}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
depends_on:
mysql:
@ -138,8 +158,10 @@ services:
frontend:
build:
context: .
dockerfile: iloom-flatten/Dockerfile
context: ./iloom-flatten
dockerfile: Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY-harbor-in-k3s.cheverjohn.me/library/}
ports:
- "${FRONTEND_PORT:-3015}:3015"
depends_on:

View File

@ -1,11 +1,16 @@
FROM golang:1.24-alpine AS builder
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library/
FROM ${BASE_REGISTRY}golang:1.24-alpine AS builder
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY shared/ ./shared/
COPY gateway/ ./gateway/
WORKDIR /app/gateway
RUN go build -o /gateway ./cmd/
FROM alpine:3.20
FROM ${BASE_REGISTRY}alpine:3.20
ARG APK_MIRROR=mirrors.aliyun.com
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi
RUN apk add --no-cache wget
COPY --from=builder /gateway /gateway
EXPOSE 8080

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,12 +65,31 @@ 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
r.GET("/ws/v1/purchaser", proxy.NewWSProxy(cfg.PurchaserServiceURL))
r.GET("/ws/v1/textile", proxy.NewWSProxy(cfg.TextileServiceURL))
r.GET("/ws/v1/washing", proxy.NewWSProxy(cfg.WashingServiceURL))
// WebSocket proxy — route by role (JWT + role check applied)
r.GET("/ws/v1/purchaser",
middleware.AuthRequired(cfg.JWTSecret),
middleware.RoleRequired("purchaser"),
proxy.NewWSProxy(cfg.PurchaserServiceURL, cfg.AllowOrigins),
)
r.GET("/ws/v1/textile",
middleware.AuthRequired(cfg.JWTSecret),
middleware.RoleRequired("textile"),
proxy.NewWSProxy(cfg.TextileServiceURL, cfg.AllowOrigins),
)
r.GET("/ws/v1/washing",
middleware.AuthRequired(cfg.JWTSecret),
middleware.RoleRequired("washing"),
proxy.NewWSProxy(cfg.WashingServiceURL, cfg.AllowOrigins),
)
addr := ":" + cfg.Port
log.Printf("gateway listening on %s", addr)

View File

@ -12,17 +12,19 @@ type Config struct {
PurchaserServiceURL string
TextileServiceURL string
WashingServiceURL string
MuyuGatewayURL string
AllowOrigins []string
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "muyu-wms-jwt-secret-key-2026"),
JWTSecret: getEnv("JWT_SECRET", ""),
AuthServiceURL: getEnv("AUTH_SERVICE_URL", "http://localhost:8081"),
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

@ -11,11 +11,29 @@ import (
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
func newUpgrader(allowedOrigins []string) websocket.Upgrader {
originSet := make(map[string]bool, len(allowedOrigins))
for _, o := range allowedOrigins {
originSet[strings.TrimSpace(o)] = true
}
func NewWSProxy(targetURL string) gin.HandlerFunc {
return websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if originSet["*"] {
return true
}
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
return originSet[origin]
},
}
}
func NewWSProxy(targetURL string, allowedOrigins []string) gin.HandlerFunc {
upgrader := newUpgrader(allowedOrigins)
return func(c *gin.Context) {
clientConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
@ -50,6 +68,7 @@ func NewWSProxy(targetURL string) gin.HandlerFunc {
go pumpMessages(backendConn, clientConn, errCh)
<-errCh
<-errCh
}
}

@ -1 +1 @@
Subproject commit 3093ae18c25f6d654e1c0c3a6cd546c04a5438d1
Subproject commit d085054437c10ef8bf09c983d115e97b0946881d

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";
}
}

View File

@ -1,3 +1,7 @@
## DEPRECATED: These raw K8s manifests are superseded by iloom-deploy/charts/.
## DataSource values must be injected via Kubernetes Secrets at deploy time.
## See iloom-deploy/charts/infra/templates/secrets.yaml for the Helm-based approach.
##
apiVersion: v1
kind: ConfigMap
metadata:
@ -13,7 +17,7 @@ data:
- etcd:2379
Key: system.rpc
DataSource: root:muyu2026@tcp(mysql:3306)/muyu_wms?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
DataSource: REPLACE_AT_DEPLOY_TIME
Cache:
- Host: redis:6379
@ -38,7 +42,7 @@ data:
- etcd:2379
Key: inventory.rpc
DataSource: root:muyu2026@tcp(mysql:3306)/muyu_wms?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
DataSource: REPLACE_AT_DEPLOY_TIME
Cache:
- Host: redis:6379
@ -60,7 +64,7 @@ data:
Port: 8888
Auth:
AccessSecret: muyu-wms-jwt-secret-key-2026
AccessSecret: REPLACE_AT_DEPLOY_TIME
AccessExpire: 7200
SystemRpc:

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

@ -1,11 +1,16 @@
FROM golang:1.24-alpine AS builder
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library/
FROM ${BASE_REGISTRY}golang:1.24-alpine AS builder
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY shared/ ./shared/
COPY purchaser-service/ ./purchaser-service/
WORKDIR /app/purchaser-service
RUN go build -o /purchaser-service ./cmd/
FROM alpine:3.20
FROM ${BASE_REGISTRY}alpine:3.20
ARG APK_MIRROR=mirrors.aliyun.com
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi
RUN apk add --no-cache wget
COPY --from=builder /purchaser-service /purchaser-service
EXPOSE 8082

View File

@ -12,7 +12,7 @@ type Config struct {
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8082"),
DBDSN: getEnv("DB_DSN", "root:muyu2026@tcp(127.0.0.1:3306)/muyu_wms?parseTime=true&charset=utf8mb4"),
DBDSN: getEnv("DB_DSN", ""),
InternalServiceKey: getEnv("INTERNAL_SERVICE_KEY", "dev-internal-key"),
InventoryRPCAddr: getEnv("MUYU_INVENTORY_RPC_ADDR", "127.0.0.1:9002"),
}

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)
}

112
scripts/deploy.sh Executable file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Deploy iloom (backend services + frontend) in integrated mode against a running muyu-apiserver stack.
#
# Prerequisites:
# - muyu-apiserver already running (cd ../muyu-apiserver/deploy && docker compose up -d)
# - .env present in repo root (copy from .env.example and fill in secrets)
#
# Usage: ./scripts/deploy.sh [--no-mirror]
# --no-mirror Build images against upstream registries (Docker Hub, proxy.golang.org,
# official Alpine CDN) instead of the Harbor/Aliyun/goproxy.cn mirrors that
# the Dockerfiles use by default.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
MUYU_NETWORK="deploy_default"
MUYU_MYSQL_CONTAINER="muyu-mysql"
SERVICES=(gateway auth-service purchaser-service textile-service washing-service frontend)
COMPOSE_FILE="docker-compose.integrated.yml"
NO_MIRROR=false
for arg in "$@"; do
case "$arg" in
--no-mirror) NO_MIRROR=true ;;
*) ;;
esac
done
log() { printf '\n==> %s\n' "$1"; }
die() { printf '\nERROR: %s\n' "$1" >&2; exit 1; }
[ -f .env ] || die ".env not found in $ROOT_DIR. Copy .env.example to .env and fill in real values first."
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" == \#* ]] && continue
export "$key=$value"
done < <(grep -v '^\s*#' .env | grep '=')
[ -n "${DB_DSN:-}" ] || die "DB_DSN is not set in .env"
[ -n "${JWT_SECRET:-}" ] || die "JWT_SECRET is not set in .env"
[ -n "${JWT_REFRESH_SECRET:-}" ] || die "JWT_REFRESH_SECRET is not set in .env"
[ -n "${INTERNAL_SERVICE_KEY:-}" ] || die "INTERNAL_SERVICE_KEY is not set in .env"
if [ "$NO_MIRROR" = true ]; then
log "Disabling build mirrors (--no-mirror): using upstream registries"
export BASE_REGISTRY=""
export GOPROXY="https://proxy.golang.org,direct"
export APK_MIRROR=""
fi
log "Checking muyu-apiserver dependencies"
docker network inspect "$MUYU_NETWORK" >/dev/null 2>&1 || \
die "Docker network '$MUYU_NETWORK' not found. Start muyu-apiserver first: cd ../muyu-apiserver/deploy && docker compose up -d"
docker inspect -f '{{.State.Running}}' "$MUYU_MYSQL_CONTAINER" 2>/dev/null | grep -q true || \
die "Container '$MUYU_MYSQL_CONTAINER' is not running. Start muyu-apiserver first."
for c in muyu-system-rpc muyu-inventory-rpc; do
docker inspect -f '{{.State.Running}}' "$c" >/dev/null 2>&1 || die "Container '$c' is not running."
done
echo "muyu-apiserver stack is up (network '$MUYU_NETWORK' found, mysql/system-rpc/inventory-rpc running)."
log "Applying iloom schema to shared muyu_wms database (idempotent)"
docker exec -i "$MUYU_MYSQL_CONTAINER" mysql -uroot -p"${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env}" < scripts/init-iloom.sql
echo "iloom tables (ilm_*) ensured in muyu_wms."
log "Building and starting iloom services: ${SERVICES[*]}"
docker compose -f "$COMPOSE_FILE" up -d --build "${SERVICES[@]}"
log "Waiting for services to become healthy"
port_for() {
case "$1" in
gateway) echo "${GATEWAY_PORT:-8080}" ;;
auth-service) echo "${AUTH_SERVICE_PORT:-8081}" ;;
purchaser-service) echo "${PURCHASER_SERVICE_PORT:-8082}" ;;
textile-service) echo "${TEXTILE_SERVICE_PORT:-8083}" ;;
washing-service) echo "${WASHING_SERVICE_PORT:-8084}" ;;
frontend) echo "${FRONTEND_PORT:-3015}" ;;
esac
}
path_for() {
case "$1" in
frontend) echo "/" ;;
*) echo "/health" ;;
esac
}
all_ok=true
for svc in "${SERVICES[@]}"; do
port="$(port_for "$svc")"
path="$(path_for "$svc")"
ok=false
for _ in $(seq 1 30); do
if curl -fsS -o /dev/null "http://localhost:${port}${path}" 2>/dev/null; then
ok=true
break
fi
sleep 2
done
if [ "$ok" = true ]; then
echo " [OK] $svc (http://localhost:${port}${path})"
else
echo " [FAIL] $svc (http://localhost:${port}${path}) did not become healthy in time"
all_ok=false
fi
done
if [ "$all_ok" = true ]; then
log "Deploy complete. All services healthy."
else
log "Deploy finished with failures. Check logs: docker compose -f $COMPOSE_FILE logs -f <service>"
exit 1
fi

View File

@ -1,11 +1,16 @@
FROM golang:1.24-alpine AS builder
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library/
FROM ${BASE_REGISTRY}golang:1.24-alpine AS builder
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY shared/ ./shared/
COPY textile-service/ ./textile-service/
WORKDIR /app/textile-service
RUN go build -o /textile-service ./cmd/
FROM alpine:3.20
FROM ${BASE_REGISTRY}alpine:3.20
ARG APK_MIRROR=mirrors.aliyun.com
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi
RUN apk add --no-cache wget
COPY --from=builder /textile-service /textile-service
EXPOSE 8083

View File

@ -12,7 +12,7 @@ type Config struct {
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8083"),
DBDSN: getEnv("DB_DSN", "root:muyu2026@tcp(127.0.0.1:3306)/muyu_wms?parseTime=true&charset=utf8mb4"),
DBDSN: getEnv("DB_DSN", ""),
InternalServiceKey: getEnv("INTERNAL_SERVICE_KEY", "dev-internal-key"),
PurchaserServiceURL: getEnv("PURCHASER_SERVICE_URL", "http://localhost:8082"),
}

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

@ -1,11 +1,16 @@
FROM golang:1.24-alpine AS builder
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library/
FROM ${BASE_REGISTRY}golang:1.24-alpine AS builder
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY shared/ ./shared/
COPY washing-service/ ./washing-service/
WORKDIR /app/washing-service
RUN go build -o /washing-service ./cmd/
FROM alpine:3.20
FROM ${BASE_REGISTRY}alpine:3.20
ARG APK_MIRROR=mirrors.aliyun.com
RUN if [ -n "$APK_MIRROR" ]; then sed -i "s|dl-cdn.alpinelinux.org|$APK_MIRROR|g" /etc/apk/repositories; fi
RUN apk add --no-cache wget
COPY --from=builder /washing-service /washing-service
EXPOSE 8084

View File

@ -12,7 +12,7 @@ type Config struct {
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8084"),
DBDSN: getEnv("DB_DSN", "root:muyu2026@tcp(127.0.0.1:3306)/muyu_wms?parseTime=true&charset=utf8mb4"),
DBDSN: getEnv("DB_DSN", ""),
InternalServiceKey: getEnv("INTERNAL_SERVICE_KEY", "dev-internal-key"),
PurchaserServiceURL: getEnv("PURCHASER_SERVICE_URL", "http://localhost:8082"),
}

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 {